Fwio

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-servervite)中被广泛使用。其本质是开启一个服务端的代理,用于转发原本从客户端直接发送将跨域的请求。由于同源政策(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。

使用fetchxhr

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

低维度上讲,如果请求不是GETPOSTHEAD请求,或者它包含 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)将使用的请求方法,即使该请求方法是寻常的(GETPOSTHEAD)也需发送该头部。(必须
  • 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 请求方法,可以是逗号分隔的列表,对大小写敏感。如果主请求不携带凭据,那么该头部可用*表示几乎所有方法,不包括CONNECTTRACETRACK(由于安全原因,它们都在 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。

如果主请求方法头部都被允许,那么主请求将被发送。