跨域技法之CORS

说到CORS,要从下面是一段很常见的前端面试对话

问: 之前实践中有遇到过跨域的需求吗?

答: 遇到过。

问: 那你是怎么解决的呢?

答: 跨域吧,大概有8种方法,很平衡,jsonp,CORS,反向代理,Websocket。结合iframe,还有使用document.domain, window.name, location.hash, window.postMessage等方法。

jsonp

jsonp是此前最为常用的一种跨域方法,利用了<script>标签的非跨域性,实现起来大概是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function jsonp(url, success) {

var ud = '_' + +new Date,
script = document.createElement('script'),
head = document.getElementsByTagName('head')[0]
|| document.documentElement;

window[ud] = function(data) {
head.removeChild(script);
success && success(data);
};

script.src = url.replace('callback=?', 'callback=' + ud);
head.appendChild(script);
}

jsonp('http://soundcloud.com/oembed?url=http%3A//soundcloud.com/forss/flickermood&format=js&callback=?', function(data){
console.log(data);
});

CORS

CORS(Cross-Origin Resource Sharing)是W3C规定的在客户端用来进行跨站通信的标准。随着XMLHttpRequest2的出现,大部分浏览器下,可以像普通同域那样使用xhr对象来发起跨域请求。

构造一个CORS请求

CORS被下列浏览器支持

  • Chrome 3+
  • Firefox 3.5+
  • Opera 12+
  • Safari 4+
  • Internet Explorer 8+

其中Chrome,FF,Opera,Safari都通过XMLHttpRequest2对象来实现CORS。不一般的IE通过XDomainRequest对象实现,不过工作方式和xhr对象大同小异。Nicolas Zakas(《JavaScript高级程序设计》的作者)写了一个helper函数,保证了浏览器兼容性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function createCORSRequest(method, url) {
var xhr = new XMLHttpRequest();
if ("withCredentials" in xhr) {

// Check if the XMLHttpRequest object has a "withCredentials" property.
// "withCredentials" only exists on XMLHTTPRequest2 objects.
xhr.open(method, url, true);

} else if (typeof XDomainRequest != "undefined") {

// Otherwise, check if XDomainRequest.
// XDomainRequest only exists in IE, and is IE's way of making CORS requests.
xhr = new XDomainRequest();
xhr.open(method, url);

} else {

// Otherwise, CORS is not supported by the browser.
xhr = null;

}
return xhr;
}

var xhr = createCORSRequest('GET', url);
if (!xhr) {
throw new Error('CORS not supported');
}

withCredentials

标准的CORS请求默认不携带cookie,如果需要在请求中携带cookie信息,需要在为xhr对象指定withCredentials属性。

1
xhr.withCredentials = true

同时,服务器端也要在响应头上设置Access-Control-Allow-Credentials字段为true。像下面这样:

1
Access-Control-Allow-Credentials: true

值得注意的是,cookie同样遵守同源法则,即你的JS代码无法获取和设置远端的cookie。

发起CORS请求

CORS请求发起方式和xhr没有什么区别,调用xhr.send()即可。综合来看,一个End to End的例子像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Create the XHR object.
function createCORSRequest(method, url) {
var xhr = new XMLHttpRequest();
if ("withCredentials" in xhr) {
// XHR for Chrome/Firefox/Opera/Safari.
xhr.open(method, url, true);
} else if (typeof XDomainRequest != "undefined") {
// XDomainRequest for IE.
xhr = new XDomainRequest();
xhr.open(method, url);
} else {
// CORS not supported.
xhr = null;
}
return xhr;
}

// Helper method to parse the title tag from the response.
function getTitle(text) {
return text.match('<title>(.*)?</title>')[1];
}

// Make the actual CORS request.
function makeCorsRequest() {
// This is a sample server that supports CORS.
var url = 'http://html5rocks-cors.s3-website-us-east-1.amazonaws.com/index.html';

var xhr = createCORSRequest('GET', url);
if (!xhr) {
alert('CORS not supported');
return;
}

// Response handlers.
xhr.onload = function() {
var text = xhr.responseText;
var title = getTitle(text);
alert('Response from CORS request to ' + url + ': ' + title);
};

xhr.onerror = function() {
alert('Woops, there was an error making the request.');
};

xhr.send();
}

背后

CORS背后的脏活累活包括额外的包头以及额外的报文,这些由浏览器帮我们代劳了。CORS请求分为“简单的请求”和“没那么简单的请求”。简单的请求包含下列特征:

  • HTTP方法名是GET, POST, HEAD之一
  • HTTP包头包括
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type为application/x-www-form-urlencodedmultipart/form-datatext/plain

这种普通的CORS请求可以不必选择CORS,而可以通过jsonp或表单提交的方式解决。剩下的不那么简单的请求则需要浏览器和服务器进行额外的报文交换(prelight request)。

我们先从一个简单的请求开始,利用上面封装好的函数,写出下面的一段代码

1
2
3
var url = "http://api.foo.com/cors",
xhr = createCORSRequest('GET', url);
xhr.send();

在它的背后的HTTP包头像下面这样,请注意其中的Origin字段。

1
2
3
4
5
6
GET /cors HTTP/1.1
Origin: http://api.bar.com
Host: api.foo.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...

一个合理的CORS请求必须包含一个Origin包头。这个包头由浏览器添加,用户不可自行修改。这部分由协议,域名和端口三部分组成。三者任意一个与Host不一致就算做跨域。非跨域时,不同浏览器对这个字段的处理方式不同,如FF会省去Origin字段,Chrome和Safari会在POST/DELETE/PUT时包括这个字段。

幸运的是,在跨域时一定会带上这个字段。支持CORS会根据客户端的Origin是否在allow list中做出回应。下面是一个样例

1
2
3
4
Access-Control-Allow-Origin: http://appi.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

其中前三个以Access-Control-开头的字段和CORS相关。

  • Access-Control-Allow-Origin(必须),这个字段必须附加在合法的CORS响应中,像上面例子所写的那样,或是设置为*表示允许任意源的请求(不过一般不会这样设置)
  • Access-Control-Allow-Credentials(可选),默认情况下,cookie不被包含在CORS请求中,设置此字段为true表示包含cookie在请求中。这个字段需要和XMLHttpRequest2中的withCredentials属性配合保证成功。
  • Access-Control-Expose-Headers(可选),XMLHttpRequest2的getResponseHeader()方法可以获取下面这些属性
    • Cache-Control
    • Content-Language
    • Content-Type
    • Expires
    • Last-Modified
    • Pragma
      如果想访问其他属性时,需要设置这个字段,属性间用逗号隔开。

当请求“没那么简单”时,比如徐需要使用PUT或DELETE,或是需要支持JSON的返回资源类型时,浏览器需要先发起一次prelight request。在收到服务器允许的回复后,真实的请求再发出。不过这个过程对于用户是透明的。下面是一个例子

1
2
3
4
var url = "http://api.foo.com",
xhr = createCORSRequest('PUT', url);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

Prelight request如下:

1
2
3
4
5
6
7
8
OPTIONS /cors HTTP/1.1
Origin: http://api.bar.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.foo.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...

除了我们上面已经提过的Origin字段外,又新增了两个字段

  • Access-Control-Request-Method 真实的HTTP请求方法。是始终包含在包头的
  • Access-Control-Request-Headers 用逗号分隔的真实HTTP请求的包头。

服务器接收到这些后,会根据方法和包头,结合Origin检验合法性。在验证合法后,返回Prelight Response

1
2
3
4
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8

它们的含义从名字中就可以推出一二

  • Access-Control-Allow-Origin(必须) 略
  • Access-Control-Allow-Methods(必须),用逗号分隔的支持的HTTP方法列表。尽管可能请求中只写了一种方法。这么做避免了多次请求
  • Access-Control-Allow-Headers (若客户端填写对应的头部则为必须),逗号分割的支持的所有头部类型
  • Access-Control-Allow-Credentials(可选) 略
  • Access-Control-Max-Age(可选),prelight response的缓存时间,以秒为单位。

Prelight requestPrelight response交换完成后,就是真正的请求和响应。此时的请求头部可以带上之前商议中所允许的字段,大致像下面这样:

1
2
3
4
5
6
7
PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

当请求成功发出时,可以在响应头部看到CORS相关的字段,如Access-Control-Allow-Origin。请求失败时,会在console上报错,但不会给出具体信息。

CORS from jQuery

关于使用jQuery发起CORS请求,可以参加后文参考的第一条或jQuery相关文档。

参考