How to win at CORS?
Reference: How to win at CORS - JakeArchibald.com
CORS 的本质:为在客户端访问跨域资源,对 HTTP 请求(Request)和响应(Response)附加的一系列头部信息(Access-Control-*
),以及 W3C 标准对此做的一些相关支持,统称为一个 CORS 标准(Spec)。
CORS 与反向代理
CORS 和反向代理(Reverse Proxy)是解决跨域问题的两种不同的方式。
由于前后端分离架构的流行,反向代理在前端开发环境的 DevServer (如
webpack-dev-server
、vite
)中被广泛使用。其本质是开启一个服务端的代理,用于转发原本从客户端直接发送将跨域的请求。由于同源政策(SOP)只限制 C => S 的请求,而从反向代理到目标服务器属于 S => S,所以自然不受同源政策的限制。在 DevServer 设置的反向代理是影响不到生产环境的,需要额外在生产服务器(如
nginx
等)配置反向代理。
Making a CORS request
大多数现代 Web 特性都默认要求使用 CORS,比如fetch()
。
有些现代 Web 特性,如<link rel="preload">
,默认不使用 CORS,因为它们被设计出来用于支持一些不使用 CORS 的旧特性。
并没有一套简单的规则用于鉴别哪些特性默认使用或不使用 CORS,比如:
<!-- Not a CORS request-->
<script src="https://example.com/script.js"></script>
<!-- CORS request -->
<script type="module" src="https://example.com/script.js"></script>
最佳方式是查看 HTTP 请求头部,在 Chrome 和 Firefox 中,跨域请求将携带Sec-Fetch-Mode
头部,来声明其是否是一个 CORS 请求。
GET ... HTTP/1.1
Sec-Fetch-Mode: cors | no-cors | ...
如果一个 HTML 元素默认未使用 CORS 进行了请求,可以通过crossorigin
属性,将其行为切换到 CORS 请求。
<img crossorigin src="..." />
<script crossorigin src="..."></script>
<link crossorigin rel="stylesheet" href="..." />
<link crossorigin rel="preload" as="font" href="..." />
CORS requests
跨域的 CORS 请求默认不携带 credentials(没有 cookies、客户端证书、自动添加的Authorization
头部,且响应头中的Set-Cookie
将被忽略),而同源请求默认携带 credentials。
注意
本文以及通常 HTTP 请求所指的携带凭据(credentials)都是指浏览器能自动添加到请求的凭据。
在开发 CORS 标准时,标准组织新建立了一个 HTTP 头部Origin
,用来提供发出请求的页面的源。
关于 HTTP
Referer
HTTP
referer
被浏览器传递给服务器,用来告知服务器“用户进入当前页(即目前请求的目标)之前所处的页面”。为什么
Referer
(在提供安全支持上)非常鸡肋?因为
referer
是可欺瞒的(spoofable),在客户端可以修改它(如通过浏览器扩展、代理等),而origin
头部是由浏览器内部控制,保证其准确可靠的。
Origin
是一个很有用的头部,常常被添加到许多其他类型的请求中,比如 WebSocket 或POST
请求。浏览器曾试过将Origin
添加到常规的GET
请求中,但这会导致许多将带有Origin
头部的请求视为 CORS 请求的网站崩溃。
CORS responses
为使其他源能跨域访问资源,响应头必须包含头部:
Access-Control-Allow-Origin: * (表示接受任何源)
如果请求没有附带 credentials,那么*
代表任何请求的源。若要显式指定源,注意所有 HTTP 头部的名称不区分大小写,但值区分大小写(case-sensitive)。
注意,如果请求的Origin
相对于响应的Access-Control-Allow-Origin
多了一个尾缀/
,也不能通过 CORS。
如果请求通过 CORS,则响应可能包含以下额外的头部信息:
Cache-Control
:缓存管理Content-Language
:语言Content-Length
:消息主体的大小Content-Type
:资源的 MIME 类型Expires
:响应过期时间Last-Modified
:最后一次修改时间Pragma
:已弃用,用于缓存管理
响应可以包含另一个头部Access-Control-Expose-Headers
,让服务端可以发送额外的自定义头部:
Access-Control-Expose-Headers: Custom-Header-1, Custom-Header-2
Is it safe to expose resources via CORS?
如果一个资源不包含隐私数据,那么对其加上Access-Control-Allow-Origin: *
是完全安全的。
如果一个资源有时,取决于 cookies,会包含隐私数据,那么在加上Access-Control-Allow-Origin: *
后,需要包含Vary: Cookie
控制缓存以保证其安全。
Adding credentials
跨域的 CORS 请求默认是不包含 credentials 的。然而,各种 API 允许添加 credentials。
使用fetch
或xhr
:
// Fetch API
const response = await fetch(url, {
credentials: 'include',
})
// AJAX
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.withCredentials = true
xhr.send()
或者 HTML 元素:
<img crossorigin="use-credentials" src="..." />
那么响应必须包含:
...
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://<subdomains>.com
Vary: Cookie, Origin
注意
如果要在 CORS 请求中携带凭据,则必须要服务端配合:
- 添加头部
Access-Control-Allow-Credentials: true
;- 不能使用通配符
*
指定接收所有源,即Access-Control-Allow-Origin
不可为*
Unusual requests and preflights
先看一些常见的(usual)请求:
// 这个请求与一个<img>元素的行为几乎相同
fetch(url, { credentials: 'include' })
// 上面这个请求与一个<form>元素的行为几乎相同
fetch(url, {
methdo: 'POST',
body: formData,
})
// 这不是一个寻常请求,它具有特殊的请求方法和头部
fetch(url, {
method: 'wibbley-wobbley',
credentials: 'include',
headers: {
fancy: 'headers',
'here-we': 'go',
},
})
怎样判定一个请求算是一个 unusual request 相当复杂。
从高维度上讲,如果它不是其他浏览器 API 通常发出的那种请求,那么它就是 unsual request。
从低维度上讲,如果请求不是GET
、POST
或HEAD
请求,或者它包含 safelist 以外的头部或头部值,那么它是一个 unusal request。
在发送 unsual request 之前,浏览器会先询问目标源是否接受它,这个过程叫做预检(preflight),这个行为是浏览器自动进行的。
Preflight request
对于前面的 unusual request,浏览器先用OPTIONS
方法发送预检请求,且带有以下头部:
Access-Control-Request-Method: wibbley-wobbley
Access-Control-Request-Headers: fancy, here-we
Access-Control-Request-Method
:主请求(main request)将使用的请求方法,即使该请求方法是寻常的(GET
、POST
、HEAD
)也需发送该头部。(必须)Access-Control-Request-Headers
:主请求将要发送的特殊头部,如果没有特殊头部,预检请求将不发送该头部。(可选)
预检请求从不携带 credentials,即使主请求可能携带。
Preflight response
服务器对预检请求的响应,将使用以下头部表明是否允许主请求:
Access-Control-Max-Age: 600 // 600s
Access-Control-Allow-Methods: wibbley-wobbley
Access-Control-Allow-Headers: fancy, here-we
Access-Control-Max-Age
- 预检响应的缓存周期,在周期内发送 unusual 主请求不需要再进行预检。默认值是 5 s,一些浏览器限制了其最大值,如 Chrome(10 min)、Firefox(24 h)。Access-Control-Allow-Methods
- 允许的 unsual 请求方法,可以是逗号分隔的列表,对大小写敏感。如果主请求不携带凭据,那么该头部可用*
表示几乎所有方法,不包括CONNECT
、TRACE
、TRACK
(由于安全原因,它们都在 FORBIDDEN LIST 上)。Access-Control-Allow-Headers
- 允许的 unusual 头部,同样可以是逗号分隔的列表,对大小写不敏感。如果主请求不携带凭据,该头部可使用*
表示允许所有不在 HEADER FORBIDDEN LIST 上的头部。
由于安全问题,列于 HEADER FORBIDDEN LIST 中的头部必须处于浏览器的控制下,它们会静默从 CORS 请求和Access-Control-Allow-Headers
中被抹除。
预检响应必须通过常规的 CORS 检查,所以其需要加上Access-Control-Allow-Origin
,且如果主请求将发送凭据,预检响应必须带上Access-Control-Allow-Crendentials: true
。预检响应的状态码必须在 200-299 中。
注意
预检响应对状态码的限制造成了一些陷阱。对于一个像
/artists/Pip-Blom
这样的 API,如果对应资源 ‘Pip Blom’ 不在数据库中,你可能想要返回 404 来明确告知客户端它请求了不存在的资源。但如果这个请求需要一个预检请求,那么其响应状态码只能是 200-299,即使最终的主响应会是 404。
如果主请求方法和头部都被允许,那么主请求将被发送。