CORS 控制头信息完全解读

Last Modified: 2022/11/08

概述

CORS 是一个 W3C 标准,全称是“跨域资源共享”(Cross-origin resource sharing)。说到跨域,那么肯定要提到同源,因为同源的对立面就是跨域。

同源可以定义为一个三元组 <schema, port, host>,其中 schema 是协议。 如果两个两个 URL 的三元组是相同的,就是同源的,否则就是跨域。这里不会详细介绍同源策略,有需要的同学可以到 MDN 上查看。

本文主要对 CORS 的各种控制头做一个概要性描述,帮助大家从宏观上了解各个控制头的作用和用法。但是在这之前我们先介绍下简单请求和请求预检,以便读者能够理解后面的内容。

简单请求

一些请求不会触发请求预检,这类请求被称为“简单请求”,虽然这个名词没有在 CORS 规范定义,但是 MDN 上使用该说法,为了方便交流,我们也使用该名称。

一个请求是简单请求,必须满足以下条件:

  • 请求方法必须是 GET/HEAD/POST 之一;
  • 除了浏览器自动携带的头部,例如:Connection,User-Agent等,其他可以手动设置的请求头只能定义在安全请求头中。

安全请求头

安全请求头必须是以下之一:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type,且 Content-Type 的取值必须是 application/x-www-form-urlencodedmultipart/form-datatext/plain三者之一。
  • Range (only with a simple range header value; e.g., bytes=256- or bytes=127-255)

注意:由于 Content-Type 取值的限制,可以看出发送 JSON 数据到服务器不同于发送表单数据,并不是简单请求,因此会触发请求预检。

非简单请求和请求预检

上节中,已经定义了简单请求,不满足简单请求定义的请求都是非简单请求。非简单请求在请求真正发出之前,浏览器会自动触发“请求预检”。即使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。“预检请求”的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

CORS 相关的一些控制头

这里先罗列一下 CORS 相关的一系列控制头,然后再逐个详细说明:

  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers
  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials
  • Access-Control-Expose-Headers
  • Access-Control-Max-Age

除去 Access-Control-Allow-Credentials、Access-Control-Expose-Headers 和 Access-Control-Max-Age,剩下的六个可以分成三组:

  • (Origin, Access-Control-Allow-Origin)
  • (Access-Control-Request-Method, Access-Control-Allow-Methods)
  • (Access-Control-Request-Headers, Access-Control-Allow-Headers)

我们的分组原则是把请求头和该请求头对应的响应头分组在一起。 以 a.com,访问 b.com 为例说明,简单说下这些控制头的作用。

第一组

由于是 a.com 访问 b.com,所以 Origin 是 http://a.com,如果 b.com 响应头 Access-Control-Allow-Origin 的值是 http://a.com 或者是 *,代表 b.com 接受 a.com 的访问,因此浏览器不会阻止来自 b.com 的响应。

第二组

假设 a.com 发送一个 PUT 请求到 b.com。我们知道 PUT 请求不是简单请求,因此浏览器会自动触发请求预检。浏览器发出 OPTIONS 请求,并且请求头中包含:

Access-Control-Request-Method: PUT

b.com 收到预检请求之后,需要做出回应是否允许来自 a.com 的 PUT 请求。如果 b.com 允许,那么 b.com 的响应头 Access-Control-Allow-Methods 的值必须包含 PUT 或者为 *。

第三组

假设 a.com 发送一个请求到 b.com,但是包含了一个自定义请求头“MY-XID”,从上面简单请求的定义得知该请求是非简单请求,因此浏览器会自动触发请求预检。浏览器发出 OPTIONS 请求,并且请求头中包含:

Access-Control-Request-Headers: MY-XID

b.com 收到预检请求之后,需要做出回应是否允许来自 a.com 的请求。如果 b.com 允许,那么 b.com 的响应头 Access-Control-Allow-Headers 的值必须包含 MY-XID 或者为 *。

控制头详解

Origin

Origin 字面意思是源头,该请求头用于表示请求是从哪里发起的。Origin 由 schema、hostname 和 port 三部分组成的。服务器端可以使用 Access-Control-Allow-Origin 授权。

Access-Control-Request-Method

Access-Control-Request-Method: <method>

请求预检用这个请求头告诉服务器接下来的请求将会使用的请求方法,服务器端可以用 Access-Control-Allow-Methods 来授权请求方法。

Access-Control-Request-Headers

Access-Control-Request-Headers: <header-name>, <header-name>, …

请求预检用这个请求头告诉服务器接下来的请求将会使用的请求头,服务器端可以使用 Access-Control-Allow-Headers 来响应,浏览器根据响应拒绝请求或者继续请求。

Access-Control-Allow-Origin

#允许任何来源
Access-Control-Allow-Origin: *
#允许指定来源,只允许设置一个值
Access-Control-Allow-Origin: <origin>
#mdn中提到这个不应该使用,不赘述
Access-Control-Allow-Origin: null

*的含义:对于不包含凭据的请求,如果服务器响应“*”,则表示服务器允许任意来源的请求。尝试使用通配符来响应包含凭据的请求会导致错误

用于告诉浏览器,服务端允许的请求来源。如果请求的 Origin(来源),不是服务器服务允许的来源,浏览器会阻断请求。这里的阻断请求确切的说分几种情况:

  • 如果是简单请求,那么请求能到达服务器,但是响应被浏览器丢弃;
  • 如果不是简单请求,那么会触发请求预检,请求直接被阻断。

Access-Control-Allow-Methods

#允许多种请求方法,方法之间使用逗号分隔
Access-Control-Allow-Methods: <method>, <method>, …
#通配符,允许所有的请求方法
Access-Control-Allow-Methods: *

用在请求预检的响应中,目的是告诉浏览器,服务端允许的请求方法。如果请求预检的中的请求方法,不在服务器端允许方法之列,浏览器会阻断请求。

Access-Control-Allow-Headers

Access-Control-Allow-Headers: [<header-name>[, <header-name>]*]
Access-Control-Allow-Headers: *

用在请求预检的响应中,目的是告诉浏览器,服务端允许的请求头。如果请求预检的中的请求头,不在服务器端允许之列,浏览器会阻断请求。

Access-Control-Allow-Credentials

Access-Control-Allow-Credentials: true

如果请求包含凭证(cookies、Authorization 请求头或者 TLS client certificates)。服务器端使用该响应头指示浏览器允许。如果不允许,则浏览器会丢弃响应。 当然如果是请求预检中的响应包含该响应头,则浏览器会阻断请求。

注意:如果响应中包含该了该响应头,那么 Access-Control-Allow-Origin 不能为*号,必须是明确指定 Origin。

Access-Control-Expose-Headers

Access-Control-Expose-Headers: [<header-name>[, <header-name>]*]
Access-Control-Expose-Headers: *

Access-Control-Expose-Header 响应头用于服务器端指示浏览器哪些响应头可以暴露给浏览器中运行的脚本。

默认情况下只有 安全响应头 才会暴露给脚本。 其他响应头必须在 Access-Control-Expose-Headers 响应头中明确列出才可被访问。

Access-Control-Max-Age

Access-Control-Max-Age: <delta-seconds>

用于指示浏览器请求预检响应可以被缓存多长时间。在这个时间段内,没必要每次都发请求预检。

为什么要对跨域请求施加限制

首先我们要知道跨域请求的种种限制,都是浏览器施加给页面上的脚本程序的。如果我们绕过浏览器,直接通过后台程序执行 RPC 调用,那么这些限制都将不复存在

为什么浏览器会对跨域调用施加那么多的限制呢?答案当然是为了安全。我想这很大一部分原因跟 cookie 有关。如果不加限制,那么恶意网站可以冒用合法用户的身份,取得合法用户的资料,甚至威胁用户的资金安全。

现在让我们假设浏览器对跨域请求没有任何限制,这天你已经登录了某银行网站 bank.com,然后很不幸你又打开了一个恶意网站 evil.com,恶意网站突然弹出一个窗口,告诉你中奖了,并且有个醒目的按钮“点击领取奖励”,于是你毫不犹豫的点了下去,然后你的钱不翼而飞了。

当你点击了那个”点击领取奖励“按钮的时候,evil.com 上一段恶意脚本已经悄无声息的执行了,将你的钱无情的转走。

$.ajax({
  url: 'http://bank.com/transfer',
  type: "POST",
  data: {
    to: 'ciro',
    amount: '1000000000',
  },
  xhrFields: {withCredentials: true},
});

evil.com 中这段恶意脚本中使用了 withCredentials: true,这是因为只有设置了这个属性的时候,XHR 才会在跨域请求的时候携带 cookie。如果没有 CORS 限制,那么恶意网站通过 cookie 轻而易举的冒用了你的身份并转走了你的钱。不过你肯定会说不用担心,我根本就没那么多钱。

有了 CORS 限制之后有什么不同呢?这里涉及两个问题:

  • 1、恶意请求会不会浏览器阻止?
  • 2、响应会不会被浏览器丢弃?

问题一的答案是请求不会被阻止。bank.com 收到请求后,还是会将你的钱转走!为什么不会被阻止呢?因为这是一个简单请求,不会触发预检(preflight),请求是被直接发出的。这也是为什么后台网站要对 CSRF 严防死守,仅依靠 CORS,也无济于事。

问题二的答案是肯定,响应是会被浏览器丢弃,但是这已经无力回天了。现在我们更进一步,假设后台服务已经正确了处理了 CSRF。也就是说所有的表单都包含一个服务器端预先生成的一次性的 token(可以作为表单的隐藏字段,随表单一起提交),提交表单的时候需要带上该 token,服务器端据此判断请求是否是伪造的。

那么此时 evil.com 上的跨站脚本还可以向之前一样实施攻击吗?由于此时服务器会校验 token,那么我们是不是可以先请求页面,然后将页面上的 token 解析出来,然后将 token 一起提交到后台,是不是就可以实施 CSRF 攻击呢?

以下分两步实施攻击,看是否能够得逞?

第一步:访问表单页面抽取 token

$.ajax({
  url: 'http://bank.com/transfer',
  type: "GET",
  xhrFields: {withCredentials: true},
});

第二步:带上 token 再次转账

$.ajax({
  url: 'http://bank.com/transfer',
  type: "POST",
  data: {
    to: 'ciro',
    amount: '100000000',
    authenticity_token: token
  },
  xhrFields: {withCredentials: true},
});

结论:在第一步中,访问页面的请求会被 bank.com 成功接收,但是响应却会被浏览器丢弃,这就导致不可能抽取出 token,因此第二步就不可能成功。

参考文章

有问题吗?点此反馈!

温馨提示:反馈需要登录