条件型 CORS 响应下因缺失 Vary: Origin 导致的缓存错乱问题

条件型 CORS 响应下因缺失 Vary: Origin 导致的缓存错乱问题

CORS,全名为跨域资源共享,是为了让不同网站的页面之间互相访问数据的机制。简单来说,CORS 的工作机制是这样的:网站 A 请求网站 B 的资源,网站 A 发起的请求会在 Origin 请求头上带上自己的源(origin)信息,如果网站 B 返回的响应头里有Access-Control-Allow-Origin响应头,且响应头的值是网站 A 的源(或者是*),那么网站 A 就能成功访问到这份资源,否则就报跨域错误。

浏览器在哪些情况下会发起 CORS 请求,哪些情况下发起非 CORS 请求,是有严格规定的。比如在一般的 <img>标签下发起的就是个非 CORS 请求,而在XHR/fetch下默认发起的就是 CORS 请求;还比如在一般的<script>标签下发起的是非 CORS 请求(所以才能有 jsonp),而在新的 <script type="module"下发起的是 CORS 请求。

CORS 请求会带上 Origin请求头,用来向别人的网站表明自己是谁;非 CORS 请求不带Origin头。根据网站有没有根据 Origin请求头动态返回不同的Access-Control-Allow-Origin响应头,我把 CORS 请求的响应分成了两种类型:

无条件型 CORS 响应

Access-Control-Allow-Origin固定写死为*(允许任意网站访问)、或者特定的某一个源(只允许这一个网站访问),不论请求头里的 Origin是什么,甚至没有 Origin也一样返回那个值。

条件型 CORS 响应

条件型 CORS 响应又分为两种情况:

1. 区分对待有无 Origin请求头

Origin请求头才会返回Access-Control-Allow-Origin响应头,没有就不返回。

2. 区分对待不同的 Origin请求头

如果想允许特定的某些个网站访问自己的资源,由于Access-Control-Allow-Origin被设计为不支持返回多个源,这就需要根据 Origin请求头的值来动态的判断出要不要加 Access-Control-Allow-Origin了。

比如我们想只允许 *.taobao.com 下的页面访问,当接受到的请求包含 Origin: https://foo.taobao.com时,需要返回 Access-Control-Allow-Origin: https://foo.taobao.com;当接受到的请求包含Origin: https://bar.taobao.com时,需要返回 Access-Control-Allow-Origin: https://bar.taobao.com;当接受到的请求包含 Origin: https://foo.tmall.com时,就不返回Access-Control-Allow-Origin头了。

条件型 CORS 响应下的缓存错乱问题

简单来说,我们浏览器里的缓存是以 URL 为 key 的,一个 URL 对应一个缓存。但如果浏览器访问了两个 URL 相同但 CORS 响应头不应该相同的资源(即条件型 CORS 响应),会如何呢?

接着上一小节举的例子,比如在同一个浏览器下,先打开了foo.taobao.com上的一个页面,访问了我们的资源,这个资源被浏览器缓存了下来,和资源内容一起缓存的还有Access-Control-Allow-Origin: https://foo.taobao.com响应头。这时又打开 bar.taobao.com上的一个页面,这个页面也要访问那个资源,这时它会读取本地缓存,读到的 Access-Control-Allow-Origin头是缓存下的 https://foo.taobao.com 而不是自己想要的 https://bar.taobao.com,这时就报跨域错误了,虽然它应该是能访问到这份资源的。

上面举的例子是“区分对待不同的Origin请求头”这类条件型 CORS 响应下引起的缓存错乱,这种问题是需要用户访问多个网站(foo.taobao.combar.taobao.com)后才可能触发的问题。“区分对待有无Origin请求头”也可能会造成类似的问题,而且在同一个站点下就有可能触发,比如用户先访问了foo.taobao.com的一个页面 A,页面 A 里用<img>标签加载了一张图片,注意这时候这张图片已经被浏览器缓存了,并且缓存里没有 Access-Control-Allow-Origin响应头,因为<img>发起的请求不带Origin请求头,此时用户又访问了foo.taobao.com的另一个页面 B,页面 B 里用 XHR 请求同一张图片,结果读了缓存,没有发现 CORS 响应头,报了跨域错误。在一些场景下,页面 A 和页面 B 有可能会是同一个页面,也就是说在同一个页面里就有可能触发这个问题。

使用 Vary: Origin 让同一个 URL 有多份缓存

有一个 HTTP 响应头叫Vary,vary 这个单词的意思是“变化”、“不同”的意思,Vary响应头就是让同一个 URL 根据某个请求头的不同而使用不同的缓存。比如常见的Vary: Accept-Encoding表示客户端要根据Accept-Encoding请求头的不同而使用不同的缓存,比如 gizp 的缓存一份,未压缩的缓存为另一份。

在 CORS 的场景下,我们需要使用Vary: Origin来保证不同网站发起的请求使用各自的缓存。比如从foo.taobao.com发起的请求缓存下的响应头是:

Access-Control-Allow-Origin: https://foo.taobao.com
Vary: Origin

的话,bar.taobao.com在发起同 URL 的请求就不会使用这份缓存了,因为Origin请求头变了。还有<img>标签发起的非 CORS 请求缓存下的响应头是:

Vary: Origin

的话, 在使用 XHR 发起的 CORS 请求也不会使用那份缓存,因为Origin请求头从无到有,也算是变了。

Fetch 规范中专门讲了Vary: Origin在 CORS 响应中该如何使用 fetch.spec.whatwg.org/#,其中第一段是:

If CORS protocol requirements are more complicated than setting `Access-Control-Allow-Origin` to * or a static origin, `Vary` is to be used.

翻译一下就是“如果你的 Access-Control-Allow-Origin响应头不是简单的写死成了*或者某一个特定的源(就是我总结的条件型 CORS 响应),那么你就应该加上Vary: Origin响应头。

In particular, consider what happens if `Vary` is not used and a server is configured to send `Access-Control-Allow-Origin` for a certain resource only in response to a CORS request. When a user agent receives a response to a non-CORS request for that resource (for example, as the result of a navigation request), the response will lack `Access-Control-Allow-Origin` and the user agent will cache that response. Then, if the user agent subsequently encounters a CORS request for the resource, it will use that cached response from the previous non-CORS request, without `Access-Control-Allow-Origin`.

这第二段是特别指出“区分对待有无 Origin 请求头”的同时不加Vary:Origin头会引起缓存错乱问题。

真实案例

Amazon S3,全名为亚马逊简易存储服务,可以上传任意的资源文件,然后提供 HTTP 协议方式访问。既然是个共用的第三方服务,当然就有配置 CORS 响应头的功能,然而它们就犯了规范中专门强调的这个错误:没有Origin请求头就不返回Access-Control-Allow-Origin,同时Vary: Origin也没有返回。

这个 bug 已经存在至少 7 年了,Chrome 的 bug 列表已经有一堆 WontFix 的 bug:

bugs.chromium.org/p/chr

bugs.chromium.org/p/chr

bugs.chromium.org/p/chr

bugs.chromium.org/p/chr

bugs.chromium.org/p/chr

还有 Stack Overflow 上也很多人问的,你可以在 Google 里搜索 Vary: Origin CORS,前几跳结果里就有。不过不知道为什么,Amazon 就是不修。

和 Amazon S3 对标的服务国内也有很多,比如阿里云的 OSS。是的,阿里云的 OSS 也有同样的 bug,有人也反馈过,还写了 demo 页面,这个页面里先用普通的<img>对一张图片发起了请求(非 CORS,不带Origin),然后又用带crossorigin属性的<img>对同一张图片发起请求(CORS 请求,带Origin),结果后者报错了,但如果禁用浏览器缓存,就不会报错:

https://www.zhihu.com/video/998208393819082752

而且比 S3 错误更大的地方是,S3 最起码在有Origin请求头的时候是会返回Very: Origin的,而 OSS 在任何时候都不返回,也就是说前面我举例的因“区分对待不同的Origin请求头” 引起的缓存错乱问题在 OSS 上也一并存在。和 Amazon 一样,阿里云目前也没修复该 bug。

如何解决

如果服务提供商就是不修,只能自己解决。可以通过增加额外的 URL 参数的方式,比如在非 CORS 请求场景下不加额外参数,在 CORS 场景下加个 ?cors,这样就不会使用同一份缓存了。

总结

如果你要自己实现 CORS 功能,注意遵守下面的准则:如果是写死的 Access-Control-Allow-Origin,一定不要加 Vary: Origin,如果是根据 Origin请求头动态计算出的Access-Control-Allow-Origin,一定要始终加上Vary: Origin,即便在没有 Origin请求头的情况。

当然,本文讨论的仅限可缓存的静态资源,如果是为动态接口设置 CORS,反正都不允许缓存,当然也就没这个问题了。

编辑于 2018-07-05

文章被以下专栏收录