查看原文
其他

【第2230期】CORS完全手册之一起看规范

huli 前端早读课 2021-10-21

前言

规范解读。今日前端早读课文章由@huli授权分享。

正文从这开始~~

当你获得了一个知识之后,要怎样才能知道那是正确的还是错误的?在程序的领域中这其实是一个相对简单的问题,只要去确认规范是怎么写的就可以了(如果有规范的话)。

举例来说,JavaScript的各种语言特性在ECMAScript规范里面都找到了,为什么[] === []会是false,为什么'b' + 'a' + + 'a' + 'a'会是baNaNa,这些在规范里面都有,都会详细说明是用怎样的规则在做转换。

而Web相关的领域除了JS以外,HTML或其他相关的规范几乎都可以在w3.org或whatwg.org里面找到,资源相当丰富。

虽然说浏览器的实作有可能跟规范写的不一样(像是这篇),但规范已经是最完整而且最有权威性的一个地方了,因此来这边找准没错。

如果搜寻CORS的规范,可能会找到RFC6454-Web Origin Concept以及W3C的跨源资源共享,但其中部分都叫这一个叫做Fetch的文件给取代了。

当初我疑惑惑一阵子想说是不是自己看错,fetch跟CORS有什么关系?后来才知道原来这边的fetch跟Web API那个fetch其实不同,这份规格是定义了所有跟「抓取资料( fetch)」有关的东西,就只是它的大纲所写的:

Fetch标准定义了请求,响应以及绑定它们的过程:获取。

这一篇就让我们一起来看一下CORS相关的规范,证明我前面几篇没有在唬烂你,讲得都是有所根据的。因为规格还满长的,所以底下就是我挑几个我认为的重点讲而已,想要理解所有的规格内容,还是需要自己去看才行。

(此文章发布时所参考的规格的版本为:Living Standard —最近更新于2021年2月15日,最新的规格请参考:https://fetch.spec.whatwg.org/)

先来点简单的

规格这种东西因为很完整所以内容很多也很杂,如果不先从简单一点的开始很容易会看不下去。而最简单的就是开头的目标

目标是统一跨Web平台的提取,并提供涉及所有内容的一致处理,包括:

  • URL schemes

  • Redirects

  • Cross-origin semantics

  • CSP

  • Service workers

  • Mixed Content

  • Referer

为此,它还取代了Origin最初在The Web Origin Concept中定义的HTTP标头语义

这份规格统一整了所有“ fecthing”相关的东西,例如说我们最关注的CORS或其他相关的操作。然后也有提及说这份取代了原本的RFC6454-Web Origin Concept。

接着在前言中有写到:

从高层次上讲,获取资源是一个相当简单的操作。请求进入,响应出来。但是,该操作的细节非常复杂,过去常常没有仔细写下来,并且每个API都不相同。

fetch看起来很简单,不过就是发个请求然后接收响应而已,但实际上其实水很深,以前没有规格记录下来导致每个API的实作都不一样,这也是为什么会有这个统一的spec诞生。

许多API提供了获取资源的功能,例如HTML的img和脚本元素,CSS的光标和列表样式图像,navigator.sendBeacon()和self.importScripts()JavaScript API。Fetch Standard为这些功能提供了统一的体系结构,因此在获取的各个方面(例如重定向和CORS协议)方面,它们都是一致的。

这边提到了我在前面所说的,抓取资料或跨来源抓取取资源并不只局限在AJAX上面,加载图片或CSS也是抓取资源的一种,而此规格就是为了统一管理这些行为。

Fetch标准还定义了fetch()JavaScript API,该API以相当低的抽象级别公开了大多数网络功能。

身为Fetch规范,定义JS中的fetch()API也是相当合情合理的事情。

简单的部分就到这边了,这边就只是在讲说为什么会有这份规格还有它想达成的目的是什么。

接着我们来看一下Origin的定义。

Origin

起源的部分在3.1。Origin标头,里面有附上ABNF,用特定格式写成的规则:

  1. Origin = origin-or-null


  2. origin-or-null = origin / %s"null" ; case-sensitive

  3. origin = scheme "://" host [ ":" port ]

简单来说就是origin的内容只会有两种,一种是"null",注意这边我特别用引号括住,因为那是一个字串。。

这边的的是与旧的rfc6454的区别,在旧的规范中origin其实可以是一个列表的:

  1. 7.1. Syntax


  2. The Origin header field has the following syntax:


  3. origin = "Origin:" OWS origin-list-or-null OWS

  4. origin-list-or-null = %x6E %x75 %x6C %x6C / origin-list

  5. origin-list = serialized-origin *( SP serialized-origin )

  6. serialized-origin = scheme "://" host [ ":" port ]

  7. ; <scheme>, <host>, <port> from RFC


  8. 7.2 Semantics


  9. In some cases, a number of origins contribute to causing the user

  10. agents to issue an HTTP request. In those cases, the user agent MAY

  11. list all the origins in the Origin header field

总在呢,origin的定义就跟我之前讲的一样,是scheme + host + port的组合。

再来我们直接去看我们最想知道的CORS!

CORS

CORS的部分在3.2。CORS protocol的地方。开头的介绍非常重要。

为了允许跨源共享响应并允许比HTML的form元素更多的获取功能,存在CORS协议。它位于HTTP之上,并允许响应声明它们可以与其他来源共享。

CORS协议存在是为了让网页可以有除形式之外的元素,也可以抓取跨来源资源的方法。然后这个procotol是建立在HTTP之上的。

它必须是一种选择加入机制,以防止数据从防火墙(内部网)后面的响应中泄漏出来。此外,对于包含凭证的请求,必须选择加入以防止泄漏可能敏感的数据。

这边提到了“防止从防火墙后的响应泄漏数据(内联网)”,其实就是我第一篇文章中所提到的案例。轻易取得。

而“对于包含凭据的请求,则需要选择加入”,也是我们之前所提到的,如果请求具有包含凭据(通常是cookie),就必须选择加入,否则也会有资讯泄漏的风险。

接下来底下3.2.1。一般的这一段也很重要:

CORS协议由一组标头组成,这些标头指示响应是否可以跨域共享。

对于比HTML的form元素更复杂的请求,将执行CORS预检请求,以确保请求的当前URL支持CORS协议。

这边提到了两个重点,第一个是CORS是透过header来决定一个回应是不是能被跨来源共享,这就是我在上一篇里面所说的:

说穿了,CORS就是通过由一堆的response header来跟浏览器讲说某些东西是前端有权限访问的。

第二个是如果一个请求超过HTML的形式元素可以表达的范围,那就会有一个CORS-preflight请求。

那到底怎样叫做“超过form元素可以表达的范围”?这个我们后来再看,先来看底下这两个部分:

3.2.2。HTTP请求

CORS请求是包含Origin标头的HTTP请求。不能可靠地将其标识为参与CORS协议,因为Origin标头也包含在方法为GET或的所有请求中HEAD。

这边满特别的,如果我没有理解错误的话,是说一个HTTP请求如果包含origin这个标头,就叫做CORS request,而不是代表这个请求就跟CORS procotol有关,因为除了GET跟HEAD之外的请求都会带上origin这个标头。

为了验证这个行为,我建立了一个简单的表单:

  1. <form action="/fwc/test" method="POST">

  2. <input name="a" />

  3. <input type="submit" />

  4. </form>

然后方法那边POST跟GET都试试看,发现果真是这样没错。GET的没有带origin头,但是POST的有。所以按照规格上的引用,用表格POST送出资料到同一个origin底下,也会被叫做CORS request,奇怪的知识又增加了。

CORS预检请求是一种CORS请求,用于检查是否理解了CORS协议。它OPTIONS用作方法,并包含以下标头:

Access-Control-Request-Method 指示将来对同一资源的CORS请求可能使用的方法。

Access-Control-Request-Headers 指示将来对同一资源的CORS请求可能使用哪些标头。

而CORS-preflight request就是利用OPTIONS来确认服务器是不是理解CORS procotol。

这边有一点要特别提,就叫做MDN上面写的:

部分请求不会触发CORS预检。

在Fetch的规范中并没有出现简单请求这个词,只有区分会不会触发CORS-preflight request而已。

而CORS协议当中的预检要求会带这两个标头:

  • Access-Control-Request-Method

  • Access-Control-Request-Headers

来说明之后的CORS请求可能会用到的方法跟header,这我们在上一篇也有提过了。

接下来有关response的部分:

3.2.3。HTTP响应

HTTP对CORS请求的响应可以包括以下标头:

Access-Control-Allow-Origin 指示是否可以通过返回Origin请求标头的文字值(可以是null)或*在响应中共享响应。

Access-Control-Allow-Credentials 指示当请求的凭据模式为“包括”时是否可以共享响应。

这两个是针对CORS请求可以返回的响应标头,已经在上一篇文章里面提到过了。

对CORS-preflight请求的HTTP响应可以包含以下标头:

Access-Control-Allow-Methods 指示出于CORS协议的目的,响应的URL支持哪些方法。

Access-Control-Allow-Headers 指示出于CORS协议的目的,响应的URL支持哪些标头。

Access-Control-Max-Age 表示秒和秒(默认为5),由Access-Control-Allow-Methods和Access-Control-Allow-Headers标头提供的信息可以被缓存。

CORS-preflight request也是CORS request的一种,所以上面所说的针对CORS request可以给的响应也都可以给。

而另外还定义了另外三个:

  • Access-Control-Allow-Methods:可以使用某些方法

  • Access-Control-Allow-Headers:可以使用一些header

  • Access-Control-Max-Age:前两个标头可以快取多久

这边的的是第三个,基准值是5秒,所以5秒内指向同一个资源的CORS响应标头是可以重用的。

对不是CORS预检请求的CORS请求的HTTP响应也可以包含以下标头:

Access-Control-Expose-Headers 通过列出标题的名称来指示哪些标题可以作为响应的一部分公开。

针对不是preflight的CORS请求,可以提供Access-Control-Expose-Headers这个标头,用作指名有什么header可以访问。

如果没有明确指定的话,就算拿到了response还是没办法拿到header。

接着我们回来看前面提到的那个问题:“怎样会触发预检请求?”

请求预检

在4.1。Main fetch的章节中有详细叙述了抓取资源的规则,其中我们关注的是第5点中的:

设置了请求的use-CORS-preflight标志,并且设置了

请求的unsafe-request标志,并且请求的方法不是CORS安全列出的方法,或者具有请求的标头列表的CORS不安全的请求标头名称不为空

  • 将请求的响应污点设置为“ cors”。

  • 令corsWithPreflightResponse为使用带有CORS-preflight标志设置的请求执行HTTP提取的结果。

  • 如果corsWithPreflightResponse是网络错误,请使用请求清除缓存条目。

  • 返回corsWithPreflightResponse。

如果reqeust的方法不是CORS-safelisted方法,或者标头里面有CORS-不安全的请求标头名称的话,就会设置CORS-preflight标志然后进行HTTP fetch。

继续往下追的话,在HTTP fetch的流程里会判断这个标志有没有被设置,有的话就进行CORS-preflight fetch。

上面所提的东西都可以在spec中找到:

2.2.1方法

甲CORS-safelisted方法是是一种方法GET,HEAD或POST。

只有这三个方法不会触发预检。

而有关于CORS-unsafe request-header name,它会去检查headers是不是都是“ CORS-safelisted request-header”,这边的定义在2.2.2。标头的部分,基本上只有以下几个会过:

  • accept

  • accept-language

  • content-language

  • content-type

但要注意的是content-type有额外附加条件,只能是:

  • application/ x-www-form-urlencoded

  • multipart/form-data

  • text/plain

这三种。

另外,上面的标题对应的值中一定要是合法字元,至于某种是合法字元,每个标题的定义都不同,这边就不细讲了。

仔细想想其实会发现满合理的,因为以形式来说,可以填的方法就只有GET跟POST(还有一个对话框啦但是跟HTTP无关了),可以填的enctype也只有上面说的那某种,没有填的话预设就是application/x-www-form-urlencoded。

因此如果如果表单的话,确实不会超过上面那样的定义。而如果在发出request的时候超过了这个范围,就会送出preflight request。

所以想要POST送出JSON格式的资料也会触发,除非您content-type用text / plain,就可以绕过preflight request(但不建议这样做就是了)。

CORS检查

关于请求的部分应该都看完了,然后来看一下响应相关的部分。有一件我很好奇的事情,那就是该怎么验证CORS的结果是过关的?

这边可以看到4.10。CORS检查:

如果Access-Control-Allow-Origin里的origin是null的话,就失败(这边特地提示是null而不是“ null”,这我们之后会再提到)。

再来检查如果origin是*而且凭据模式不是include,就给过。

接着比对request的由来跟header里的,不同的话就回传失败。

比对到这一步的时候origin相同了,然后再看一次凭据模式,不是inlcude的话就给过。

反之则检查Access-Control-Allow-Credentials,如果是true的话就给过,否则就回传失败。

这一系列的检查有种Early return的味道在,可能是因为这样比较好写成条列式的,试图把巢状给压平。

以上差不多就是跟CORS有关的规格了,第六章整个都在讲fetchAPI,第七章在讲websocket。

接着我们来关心一些我觉得也满重要的一些内容。

误导人的no-cors模式与fetch的流程

前面有提过fetch可以设置一个mode: no-cors,接下来我们就来看一下从规格的角度,到底实际上会做一些什么事情。

因为这是fetch request的一个参数,所以要从5.4 Request class开始看起,里面有一个阶段是:The new Request(input, init) constructor steps are:

在第30步的地方可以看到:

如果请求的方法不是GET,HEAD或POST的话,就丢一个TypeError出来。此外,也会把header's guard设成request-no-cors。

上面这只是新建一个request而已,接着可以看5.6. Fetch method来看实际送出request的流程:

前面都只是在设定一些参数,真正做动作的是第十步:

这些给定响应为processResponse的获取请求是这些子步骤

那个「Fetch」是个超连结,点下去可以连到4. Fetching的章节,而这边我们关注的是最后一步:

给定fetchParams运行主访存。

main fetch也是一个超连结,点了会跳到4.1. Main fetch去,这边一个整段专门在处理mode是no-cors时的状况:

这边有几个顶点的地方:

  • 第二步把request's response tainting设成opaque

  • 第三步去执行了「scheme fetch」

  • 第五步新建了一个回应,只有状态跟随CSP列表

  • 底下的警告

这边可以继续往scheme fetch去追,就会跟刚刚一样继续追到各种不同的fetch method,然后越越追越深。不过这边我已经帮大家追过了,再追下去其实没什么特别的,我们先假设第四步不成立好了,所以会执行到第五步:“返回状态为noCorsResponse的状态且CSP列表为noCorsResponse的CSP列表的新响应。”

而warning的部分其实满重要的:

仅当noCorsResponse与发起请求的过程保持隔离时,这才是对旁路攻击的有效防御。

这边之所以会新建的一个响应,是因为不想回传原本的响应,要让原本的响应跟发起这个请求的过程分开。为什么要这样做呢?这我们下一篇会提到。

再来我们继续往下看,可以看到第十四步:

之前已经把response tainting设成opaque,所以根据第二点,会把response设成opaque Filtered 。

那这个opaque过滤的响应是什么呢?

不透明的已过滤响应是类型为“不透明”,URL列表为空列表,状态为0,状态消息为空字节序列,标头列表为空且主体为null的已过滤响应。

这就是我们之前用mode: 'no-cors'所拿到的response,状态为0,没有header,也没有body的response。

从规格里面我们证实了我前面所说的事情,一旦mode设成no-cors,你就是拿不到response,甚至可以有设定header也一样。

使用CORS与cache时的注意事项

在规格里面有一个前提:CORS协议和HTTP缓存特别在讲这个。

先假设一个情境好了,那就是服务器只会对有带origin的请求回覆Access-Control-Allow-Origin这个header,没有带origin的话就不会回(Amazon S3就是这样做的)。然后这个响应有设定快取,所以会被浏览器快取起来。

然后假设我们现在要显示一张图片好了,这个图片在S3上方,所以是跨来源的。

我们在页面上放,浏览器载入图片,并且把响应快取起来。因为是用IMG标签的关系,所以浏览器不会带原点头,因此响应自然而然也就没有。<imgsrc="https://s3.xxx.com/a.png">Access-Control-Allow-Origin

但接下来我们在JS里面也需要拿到这个图片,因此我们用fetch去拿:,这时候这就变成了CORS request了,因此请求header会带上origin。fetch('https://s3.xxx.com/a.png')

可是呢,由于我们前面已经把这个url的响应快取起来了,所以浏览器会直接使用之前还没过期的缓存的响应。

这时候悲剧就发生了,之前快取的响应是没有Access-Control-Allow-Origin这个header的,所以CORS验证就失败了,我们就拿不到图片内容了。

那要怎么解决这个状况呢?在HTTP响应标头里面有一个Vary,用来决定这个响应的的快取可能会跟着某些请求标头而不同。

举例来说,如果传了Vary: Origin,就代表如果我之后发的request里的origin header不同,那就不应该沿用之前的快取。

以前面讲的状况而言,设置这个标头以后,我们用fetch发的请求,因为Origin标头跟之前用img的不同,所以照理来说就不会沿用之前快取好的响应,否则会重新发出一个要求。

总结

在这篇里面我们一起看了fetch的spec,从规格的尺寸去看抓取资源这件事情,也从规格里面证实了很多前面几篇文章的引用。扫过一遍,至少涉足很多东西有点印象,之后想找资料的时候会容易很多。

除此之外,也能看到一些规格中比较有趣的部分,例如说最后提的那个快取的问题,我还就真的碰到过。能复兴想到解法。

关于本文 作者:@huli 原文:https://blog.huli.tw/2021/02/19/cors-guide-4/

为你推荐


【第1990期】设计规范落地的好帮手


【第1389期】一起探讨 JavaScript 的对象


欢迎自荐投稿,前端早读课等你来

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存