一篇文章讲清楚 CORS

2024-08-07 pv

CORS 是 Cross-origin resource sharing 的简称,中文名叫:跨域资源共享

这是一个在 Web 安全领域很重要的模型。

它解决的问题是,如何在不同域下安全地共享资源

1. 背景

前端的编写十分灵活,这得益于 JavaScript 语言表达的灵活性。但是,正所谓能力越大,责任越大,这种灵活性也会给 Web 安全带来诸多挑战。默认情况下,网页主动发起的 HTTP 请求(链接跳转不算),例如 XMLHttpRequestFetch API,只支持 同源策略(Same Origin Policy)🔗,即只能访问当前域下的资源。

http://example-a.com/ 访问 http://example-b.com/ 下的资源就是跨域,请求会失败。

这在一定程度上缓解了恶意代码执行的风险。

同样也会带来一些问题:如果确实有些资源在另一个域下,怎么获取?

CORS 就是用来解决这个问题的。

2. 功能

当一个域下的网页想要访问另一个域下资源时,如果对方服务端响应的报头(HTTP header)包含正确的 CORS 信息,浏览器便不会拦截,跨域资源顺利获取。

简单来说,CORS 是一组协议,允许服务端指定哪些主机可以访问本地资源

这便是 CORS 的全部内容。

CORS 是 HTTP 标准的一部分,因此得到了现代浏览器的广泛支持。

老的浏览器可以用 JSONP🔗 的方式实现跨域访问。这是一个很有意思的 workaround,借助浏览器本身提供的特性“绕”过了同源限制。但这不是一个规范实现,容易引发安全问题,因此在现代浏览器上,最好使用 CORS。

2.1 CORS 标头

HTTP 标头中有一系列 CORS 相关的。

Access-Control-Allow-Origin
指示响应的资源是否可以被给定的来源共享。
Access-Control-Allow-Credentials
指示当请求的凭证标记为 true 时,是否可以公开对该请求响应。
Access-Control-Allow-Headers
用在对预检请求的响应中,指示实际的请求中可以使用哪些 HTTP 标头。
Access-Control-Allow-Methods
指定对预检请求的响应中,哪些 HTTP 方法允许访问请求的资源。
Access-Control-Expose-Headers
通过列出标头的名称,指示哪些标头可以作为响应的一部分公开。
Access-Control-Max-Age
指示预检请求的结果能被缓存多久。
Access-Control-Request-Headers
用于发起一个预检请求,告知服务器正式请求会使用哪些 HTTP 标头。
Access-Control-Request-Method
用于发起一个预检请求,告知服务器正式请求会使用哪一种 HTTP 请求方法。
Origin
指示获取资源的请求是从什么源发起的。
Timing-Allow-Origin
指定特定的源,以允许其访问 Resource Timing API 功能提供的属性值,否则由于跨源限制,这些值将被报告为零。

参考:跨源资源共享 - MDN Web 文档术语表:Web 相关术语的定义 | MDN🔗

当检测到跨域请求时,浏览器会默认添加相应的请求头,响应头则需要开发者在服务端设置。

2.2 工作流程

主要的阶段分为三个:

  • 浏览器发起跨域请求
  • 服务端响应
  • 浏览器处理响应

对于跨域访问,浏览器会填充 Origin 头,例如:

Origin: https://plantree.me

服务端收到请求,就会加上 Access-Control-Allow-Origin 指定允许的源,或者 * 允许所有访问。

浏览器拿到响应,判断 Access-Control-Allow-Origin 里的字段是否匹配,匹配即可拿到结果,否则报错。

2.3 场景

使用的场景主要有三个:

  • 简单请求
  • 预检请求(preflight)
  • 附带身份凭证

简单请求通常需要满足两个条件。

  • 使用的方法是下列之一
    • GET
    • HEAD
    • POST
  • 允许设置的 HTTP 头字段
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(text/plain, multipart/form-data, application/x-www-form-urlencoded)
    • Range(只允许简单的范围标头值 如 bytes=256- 或 bytes=127-255)

一次简单请求:

const xhr = new XMLHttpRequest();
const url = "https://bar.other/resources/public-data/";
xhr.open("GET", url);
xhr.onreadystatechange = someHandler;
xhr.send();

请求报文:

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

服务器响应:

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

因为 Access-Control-Allow-Origin: * 允许所有跨域访问,于是这次请求会成功响应。

与简单请求不同,预检请求需要两次访问。这些工作是浏览器自动做的。

第一次是先发送 OPTIONS 请求到服务端,检查是否允许,允许的话才会继续发送真实请求。

预检请求的例子:

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>");

因为请求的 Content-Typeapplication/xml,所以会触发预检。

首次交互结果:

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
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

允许访问后发送实际请求:

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

预检请求之所以会有两次,是因为这里的请求可能改变服务端状态,因此需要一次试探性地询问。有点像是 两阶段提交🔗

默认的跨域访问,不会携带身份信息,不过允许设置,指定 withCredentialstrue,请求时会附带 Cookies

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();
}
}

交互过程如下:

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
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

返回结果中如果缺少 Access-Control-Allow-Credentials: true,响应会失败。

3. 实际使用

在前端开发中,多数情况都是使用本域下的资源,安全,可控。

但不排除有时需要访问第三方资源,此时就需要格外注意 CORS 可能带来的问题。

对数据保护严格的网站,会设置苛刻的 CORS 策略,从而导致请求失败。

这个在 Console 窗口里很容易观察到。

尽可能少的使用外部资源,出现问题了能轻松应对,了解到这个层面,差不多就够了

4. 总结

在计算机领域里,权衡(trade-off)无处不在。

允许访问资源,但需要满足条件,提高了安全性的同时,也不可避免地降低了开发效率。

任务解决方案都是一体两面,或许这就是技术世界的辩证。

(完)

参考

  1. 浏览器的同源策略 - Web 安全 | MDN🔗
  2. JSONP - 维基百科,自由的百科全书🔗
  3. 跨源资源共享 - MDN Web 文档术语表:Web 相关术语的定义 | MDN🔗
  4. Cross-origin resource sharing - Wikipedia🔗
  5. Cross-Origin Resource Sharing (CORS) - HTTP | MDN🔗
在 GitHub 上编辑本页面

最后更新于: 2024-08-08T05:27:04+08:00