CORS意为跨源资源共享(Cross origin resource sharing),它是一个W3C标准,由一系列HTTP Header组成,这些 HTTP Header决定了浏览器是否允许JavaScript 代码成功获得跨源请求的服务器响应。
在说CORS之前,先说一下关于浏览器的同源政策(same-origin policy)。
同源政策是浏览器安全的基石,由Netscape公司引入浏览器,目前所有浏览器都实行这个政策。最初它的含义是指,A网页设置的 Cookie,B网页不能打开,除非这两个网页"同源"。所谓"同源"指的是"三个相同":协议相同,域名相同,端口相同。
举例来说,http://www.example.com/dir/page.html这个网址,协议是http://,域名是www.example.com,端口是80(默认端口可以省略)。它的同源情况如下:
http://www.example.com/dir2/other.html:同源
http://example.com/dir/other.html:不同源(域名不同)
http://v2.www.example.com/dir/other.html:不同源(域名不同)
http://www.example.com:81/dir/other.html:不同源(端口不同)
同源政策的是为了保证用户信息的安全,防止恶意的网站窃取数据。设想这样一种情况:A网站是一家银行,用户登录以后,又去浏览其他网站。如果其他网站可以读取A网站的 Cookie,会发生什么?如果 Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。
由此可见,"同源政策"是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。随着互联网的发展,"同源政策"越来越严格。目前,如果非同源,共有三种行为受到限制。
(1) Cookie、LocalStorage 和 IndexDB 无法读取。
(2) DOM 无法获得。
(3) AJAX 请求不能发送。
虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。所以有时需要一些额外的操作来打破同源政策的束缚。
Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同时,浏览器允许通过设置document.domain共享 Cookie。
举例来说,A网页是http://w1.example.com/a.html,B网页是http://w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享Cookie。
document.domain = 'example.com';
现在,A网页通过脚本设置一个 Cookie。
document.cookie = "test1=hello";
B网页就可以读到这个 Cookie。
var allCookie = document.cookie;
注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 无法通过这种方法,规避同源政策。
另外,服务器也可以在设置Cookie的时候,可以指定Cookie的所属域名为一级域名,比如:
Set-Cookie: key=value; domain=.example.com; path=/
这样的话,二级域名不用做任何设置,都可以读取这个Cookie。
同源政策规定,AJAX请求只能发给同源的网址,否则就报错。有三种方法规避这个限制:JSONP、WebSocke和CORS。
JSONP是早期服务器与客户端跨源通信的常用方法。它的基本思想是,通过在网页上添加一个<script>元素向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}
function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};
上面代码通过动态添加<script>元素,向服务器example.com发出请求。该请求的查询字符串有一个callback参数,用来指定回调函数的名字,这对于JSONP是必需的。服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。
foo({
"ip": "8.8.8.8"
});
由于<script>元素请求的脚本,直接作为代码运行。这时只要浏览器定义了foo函数,该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象,而不是字符串,因此避免了使用JSON.parse的步骤。
WebSocket是一种通信协议,使用ws://(非加密)和wss://(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
下面是浏览器发出的WebSocket请求的头信息
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
上面请求头中有一个字段是Origin,声明了该请求的源。正是因为有了Origin这个字段,所以WebSocket才没有实行同源政策。服务器可以根据这个字段,判断是否许可本次通信。如果允许这个源,服务器就会做出如下回应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
接下来就是CORS了。CORS是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是W3C标准, 堪称跨源AJAX请求的根本解决方法。相比JSONP只能发GET请求,CORS允许任何类型的请求。
同源安全策略默认阻止“跨源”获取资源,XMLHttpRequest 和 Fetch API 都遵循同源策略,这意味着使用这些 API 的 Web 应用程序只能从加载应用程序的同一个域请求 HTTP 资源。
现代浏览器支持在API容器中(例如 XMLHttpRequest 或 Fetch)使用 CORS,CORS 机制给了WEB服务器一种权限,允许服务器进行跨源访问控制。服务器可以选择在响应报文中包含正确的CORS响应头,这样浏览器就能允许JavaScript跨源访问WEB服务器提供的资源。
CORS功能概述
CORS标准新增了一组 HTTP Header字段,允许服务器声明哪些源(Origin)通过浏览器有权限访问。另外,标准要求对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预请求(preflight request),获知服务器端是否允许该跨源请求。服务器确认允许后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端是否需要携带身份凭证(Credential,例如 Cookie 和 HTTP 认证相关数据)。CORS请求失败时会产生错误,但是为了安全,在JavaScript代码层面无法获知到底具体是哪里出了问题。只能查看浏览器的控制台以得知具体是哪里出现了错误。
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
(1) 请求方法是以下三种方法之一:
- HEAD
- GET
- POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
这是为了兼容表单(form),因为历史上表单一直可以发出跨域请求。AJAX 的跨域设计就是,只要表单可以发,AJAX 就可以直接发。
凡是不同时满足上面两个条件,就属于非简单请求。
浏览器对这两种请求的处理,是不一样的。
对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中增加一个Origin字段。
下面是一个浏览器发送跨源AJAX请求是简单请求例子:
GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。而服务器根据这个值,决定是否同意这次请求。
如果Origin指定的源不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。如果Origin指定的域名在许可范围内,服务器返回的响应会包含Access-Control-Allow-Origin头信息字段:
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml[…XML Data…]
本例中,服务端返回的标头Access-Control-Allow-Origin: * 表明,该资源可以被任意外源访问。
使用 Origin 和 Access-Control-Allow-Origin 就能完成最简单的访问控制。如果 https://bar.other 的资源持有者想限制他的资源只能通过https://foo.example来访问(也就是说,非 https://foo.example 域无法通过跨源访问访问到该资源),可以这样做:
Access-Control-Allow-Origin: https://foo.example
注意: 当响应的是附带身份凭证的请求时(credentialed requests request),服务端必须明确 Access-Control-Allow-Origin 的值,而不能使用通配符“*”(后面还会再说明)。
上面的头信息之中除了Access-Control-Allow-Origin是必须的字段,还有两个可选的字段Access-Control-Allow-Credentials和Access-Control-Expose-Headers。
Access-Control-Allow-Credentials字段的值是一个布尔值,表示是否允许发送Cookie。默认情况下Cookie不包括在CORS请求之中。Access-Control-Allow-Credentials设为true,表示服务器明确许可Cookie可以包含在请求中发给服务器。这个值也只能设为true,如果服务器不允许浏览器发送Cookie,删除该字段即可。
CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到响应头中的6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定,例如:
Access-Control-Expose-Headers: FooBar
这样XMLHttpRequest对象的getResponseHeader('FooBar')可以返回FooBar的值。
withCredentials 属性
上面说到CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器指定Access-Control-Allow-Credentials: true,另一方面开发者必须在AJAX请求中打开withCredentials属性。
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
否则,即使服务器同意发送Cookie,浏览器也不会发送。
但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials。
xhr.withCredentials = false;
非简单请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。非简单请求的CORS请求,会在正式通信之前增加一次使用 OPTIONS 方法发起的预检请求(preflight request)到服务器,以获知服务器是否允许该实际请求。"预检请求“的使用可以避免跨域请求对服务器的用户数据产生未预期的影响。
预检请求会询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP方法和请求头字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。
如下是一个需要执行预检请求的 HTTP 请求:
const xhr = new XMLHttpRequest();
xhr.open("POST", "https://bar.other/resources/post-here/");
xhr.setRequestHeader("X-PINGOTHER", "pingpong");
xhr.setRequestHeader("Content-Type", "application/xml");
xhr.onreadystatechange = handler;
xhr.send("<person><name>Arun</name></person>");
上面的代码使用 POST 请求发送一个 XML 请求体,该请求的 Content-Type 为 application/xml,且使用了一个自定义的X-PINGOTHER 请求头。浏览器发现这是一个非简单请求,就自动发出一个"预检"请求,要求服务器确认可以这样请求。
下面是这个"预检"请求的HTTP头信息。
OPTIONS /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
"预检"请求用的请求方法是OPTIONS,表示这个请求是用来询问的。OPTIONS 是 HTTP/1.1 协议中定义的方法,用于从服务器获取更多信息,是安全的方法。该方法不会对服务器资源产生影响。OPTIONS预检请求中除了origin之外同时携带了下面两个标头字段:
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
Access-Control-Request-Method 告知服务器,实际请求将使用 POST 方法。Access-Control-Request-Headers告知服务器,实际请求将携带两个自定义请求标头字段:X-PINGOTHER 与 Content-Type。服务器据此决定,该实际请求是否被允许。
服务器收到"预检"请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,若确认允许跨源请求,就可以做出如下回应。
HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
服务器的响应头携带了 Access-Control-Allow-Origin: https://foo.example,限制请求的源域。Access-Control-Allow-Methods 表明服务器允许客户端使用 POST 和 GET 方法发起请求。Access-Control-Allow-Headers 表明服务器允许请求中携带字段 X-PINGOTHER 与 Content-Type。与 Access-Control-Allow-Methods 一样,Access-Control-Allow-Headers 的值为逗号分割的列表。最后,字段 Access-Control-Max-Age 给定了该预检请求可供缓存的时间长短,单位为秒,默认值是 5 秒。在有效时间内,浏览器无须为同一请求再次发起预检请求。以上例子中,该响应的有效时间为 86400 秒,也就是 24 小时。请注意,浏览器自身维护了一个最大有效时间,如果该标头字段的值超过了最大有效时间,将不会生效。当然此时服务器也可以有可选的Access-Control-Allow-Credentials: true响应头,含义与简单请求时是一样的。
如果服务器否定了"预检"请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。
预检请求完成之后,发送实际请求就跟简单请求一样,会有一个Origin
头信息字段(当然也会包含预检请求中已经说明过的自定义请求头等内容)。服务器的回应也都会有一个Access-Control-Allow-Origin
头信息字段。
POST /doc HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache<person><name>Arun</name></person>
服务器的响应:
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain[Some XML payload]
这里有一个Vary响应头。Vary响应头就是让同一个URL根据某个请求头的不同而使用不同的缓存。这里的设定是让浏览器在即使同一个URL的情况下,也能根据不同的Accept-Encoding或Orign进行不同的缓存。为什么要有这个设定?因为默认浏览器里的缓存是以URL为key,一个 URL 对应一个缓存。但如果浏览器访问了两 URL相同但CORS响应头不应该相同的资源会如何呢?
比如在同一个浏览器下,先打开了foo.taobao.com上的一个页面,访问了服务器的资源,这个资源和URL被浏览器缓存了下来,与资源内容一起缓存的还有Access-Control-Allow-Origin: https://foo.taobao.com响应头。这时又打开 bar.taobao.com上的一个页面,这个页面也访问服务器上之前的那个资源,因为URL相同,这时它会读取本地缓存。但是读到的 Access-Control-Allow-Origin头信息是缓存下的 https://foo.taobao.com 而不是自己想要的 https://bar.taobao.com,这时浏览器就报跨域错误了,虽然它应该是能访问到这份资源的。再比如用户先访问了foo.taobao.com
的一个页面 A,页面 A 里用<img>
标签加载了一张图片,注意这时候这张图片已经被浏览器缓存了,并且缓存里没有 Access-Control-Allow-Origin
响应头,因为<img>
发起的请求不带Origin
请求头,然后用户又访问了foo.taobao.com
的另一个页面 B,页面 B 里用 XHR 请求同一张图片,结果读了缓存,没有发现 CORS 响应头,浏览器报跨域错误。
在CORS的场景下,使用Vary: Origin来保证从不同网站发起的请求可以使用各自的缓存。比如从foo.taobao.com发起的请求缓存中的响应头是:
Access-Control-Allow-Origin: https://foo.taobao.com
Vary: Origin
的话,bar.taobao.com在发起同URL的请求就不会使用这份缓存了,因为Origin请求头变了。
还有<img>标签发起的非 CORS 请求缓存中的响应头是:
Vary: Origin
的话, 在使用 XHR 发起的 CORS 请求也不会使用那份缓存,因为Origin请求头从无到有,也算是变了。
最佳的实践原则是:
If CORS protocol requirements are more complicated than setting `Access-Control-Allow-Origin` to * or a static origin, `Vary` is to be used.
如果你的 Access-Control-Allow-Origin
响应头不是简单的写死成了*
或者某一个固定的源,那么你就应该加上Vary: Origin
响应头。
最后再重复说明一下前面已经提及过的附带身份凭证(credential)的请求。
对于跨源的XMLHttpRequest 或 Fetch 请求,浏览器不会发送身份凭证信息。如果要发送凭证信息,需要设置 XMLHttpRequest对象的 withCredentials标志为true,或在构造 Request 对象时设置。
本例中https://foo.example 的某脚本向 https://bar.other 发起一个 GET 请求并设置 Cookies。在 foo.example 中可能包含这样的 JavaScript 代码:
const invocation = new XMLHttpRequest();
const url = "https://bar.other/resources/credentialed-content/";
function callOtherDomain() {
if (invocation) {
invocation.open("GET", url, true);
invocation.withCredentials = true;
invocation.onreadystatechange = handler;
invocation.send();
}
}
将 XMLHttpRequest 的 withCredentials 标志设置为 true才能向服务器发送 Cookies。这是一个简单 GET 请求,所以浏览器不会对其发起“预检请求”。但是如果服务器端的响应中没有Access-Control-Allow-Credentials: true,浏览器不会把响应内容返回给请求的发送者。
客户端发起的请求头:
GET /resources/credentialed-content/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: https://foo.example/examples/credential.html
Origin: https://foo.example
Cookie: pageAccess=2
请求中指定了 Cookie 是属于 https://bar.other 的内容。如果来自服务器的响应中缺失 Access-Control-Allow-Credentials: true,则服务器响应内容会被浏览器忽略,不会提供给请求的发送者。
服务器的响应:
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain[text/plain payload]
CORS 预检请求不能包含凭据,但对预检请求的响应头中必须指定 Access-Control-Allow-Credentials: true 来表明可以携带凭据进行实际的请求。
在服务器响应附带身份凭证的请求时:
- 服务器不能将 Access-Control-Allow-Origin 的值设为通配符“*”,而应将其设置为特定的域,如:Access-Control-Allow-Origin: https://example.com。
- 服务器不能将 Access-Control-Allow-Headers 的值设为通配符“*”,而应将其设置为请求头名称的列表,如:Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
- 服务器不能将 Access-Control-Allow-Methods 的值设为通配符“*”,而应将其设置为特定请求方法名称的列表,如:Access-Control-Allow-Methods: POST, GET
另外,上面的响应标头中也携带了 Set-Cookie
字段,尝试对 Cookie 进行修改(修改pageAccess的内容)。如果操作失败,将会抛出异常。同样的,如果用户设置其浏览器拒绝所有第三方 cookie,那么将不会被保存。