HTTP: CORS

 20th August 2020 at 2:19pm

为了安全,W3C 定义了 同源策略,使得一些场景下,在网站 A 下的代码无法直接访问网站 B 的资源。HTTP 中定义的 CORS,则作为一种控制机制来让服务端限定客户端可以访问哪些资源。

哪些场景适用于 CORS?

CORS 限制的场景有这些:

  1. Invocations of the XMLHttpRequest or Fetch APIs, as discussed above.
  2. Web Fonts (for cross-domain font usage in @font-face within CSS), so that servers can deploy TrueType fonts that can only be cross-site loaded and used by web sites that are permitted to do so.
  3. WebGL textures.
  4. Images/video frames drawn to a canvas using drawImage().
  5. CSS Shapes from images.

CORS 的简单请求

当你从 foo.examplebar.other 发起一个异步请求时(用 XMLHttpRequest 或者 Fetch API),浏览器会根据回包中的 Access-Control-* 头来判断这个请求是否成功:

# 请求
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

[…XML Data…]

重点是:

  • 浏览器会自动给请求包带上 Origin 头表示发起请求的源
  • 服务器回包中的 Access-Control-Allow-Origin 头,表示服务端接受任意的来源网站来请求;它也可以是一个具体的 ,如 https://foo.example

Preflight request

CORS 这套机制考虑了安全性,对于不是「简单请求」的情况,

CORS 规范中定义了 简单请求 的概念。它是指接口行为相对「简单」的情形,要求:

  • HTTP 方法是 GETHEADPOST
  • HTTP 头中仅有限定的一批 header;比如不能有自定义的 X-* header
  • POST 场景时,Content-Type 只能是下面其一。这意味着你不能 POST 一个 JSON 的数据:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 不能使用 XMLHttpRequest.uploadReadableStream

如果不是简单请求,那意味着你的请求可能 不常见或者行为复杂,带有一定的危险。浏览器会使用 OPTION 方法先发送一个 preflight 请求服务端来确认它是否可以发送这个请求,然后再发送实际的请求。例如:

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

这段代码中使用了自定义的 X-PINGOTHER 头,以及 Content-Type 头为「简单请求」定义之外的 application/xml,因此浏览器会先发 preflight 请求:

# Preflight 请求
OPTIONS /resources/post-here/ 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: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

# Preflight 回包
# Access-Control-Allow-Headers 表示这些头是可以被使用的
# Access-Control-Max-Age 表示这个回包的信息可以用一天
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 /resources/post-here/ 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 GZIP'd payload]

可以看出,CORS 是需要客户端和服务端互相配合的机制。

Requests with credentials

如果你希望跨域请求带上对方网站的身份信息(cookie 或者 basic auth),那你可以用 XMLHttpRequest 或者 Fetch 接口中提供的控制字段 withCredentials

const invocation = new XMLHttpRequest();
const url = 'http://bar.other/resources/credentialed-content/';
    
function callOtherDomain() {
  if (invocation) {
    invocation.open('GET', url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send(); 
  }
}

带不带 withCredentials不影响 浏览器判断做不做 preflight。但是如果请求的回包中没有 Access-Control-Allow-Credentials: true 头,则浏览器会丢弃该回包并报错。

回包中的其他头字段

除了上述已经出现的头字段,还有一些字段带有控制作用:

Access-Control-Expose-Headers
表示客户端代码可以看到的、除了 CORS 定义的一个头列表之外的头字段。比如 Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header 表示客户端代码可以看到服务器返回的 X-My-Custom-HeaderX-Another-Custom-Header

Reference