浏览器中的同源策略、CORS 以及相关的 Fetch API 使用
关于浏览器中的 同源策略 (Same-origin Policy, SOP) 和 CORS (跨域资源共享), 以及一些它们在实践中的应用情况, 并介绍了一些使用 Fetch API 时的相关注意事项.

前言

笔者对前端 Web 技术的认真学习,其实开始于与 Fetch API 的邂逅。当时觉得 fetch() 的设计很不错,也很希望能够请求其它网站下的数据并作处理和展示。学习过程中 HTML 和 CSS 都还好说,由于几乎没有 Web 技术的基础,学习 Fetch API 的时候费了不少功夫,主要是其中涉及不少 HTTP 标头相关知识和习惯,也不理解 Fetch API 中很多属性和参数的意义或者为什么这么设计,更不必说 JavaScript 多种多样的版本和语法,以及十分重要的异步 I/O。

比如,在尝试中就遇到了 “blocked by CORS policy” 这样的错误。以访问 b23.tv 下的资源为例,Chrome 的提示是:

Access to fetch at ‘https://b23.tv/' from origin ’null’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ’no-cors’ to fetch the resource with CORS disabled.

说实话 Chrome 这个错误信息并不是很好,因为浏览器阻止该请求严格来说是出于 Same-origin policy (同源策略) 的限制,而 CORS policy (跨源资源共享策略) 提供了一种缓解同源策略限制的方法。由于当时对这些并不熟悉,根据关键字 CORS 寻找解决方案时也是一头雾水,不是很理解相关的原理和道理。相对来说,Firefox 的提示就好很多,还给出了相关链接共供参考。

Firefox’s ERROR message said, Cross-Origin Request Blocked: the Same Origin Policy disallows reading the remote resource at …

事后发现很多文章的内容如若用作学习,知识点之间联系不够紧密,又或者对于这些概念有所混淆,因此在本文中另作讲解,同时作为笔者学习的记录。文章中的内容主要来自 MDN 等网站,从英语翻译来,并在其中加入一些相关或必要的解释。

阅读本文需要了解一些基础 HTTP 知识(比如 HTTP 的方法和标头)、基本 URL 知识,以及 HTTPS 的基本概念。

这里吐槽一下某博主的 Fetch API 教程,且不说需要一定的 HTTP 知识或者 XHR 经验才能阅读,毕竟没有铺垫可以认为内容主要针对有经验的开发者,但是对于某些属性的解释就仅仅停留在解释上,甚至自己一知半解就去进行解释,我都很难相信作者是怎么说服自己的。有经验的人看到或许还能明白或者才出来在说什么,但对于新人来说,就是“前言不搭后语”。个人认为这样的内容对于新人来说完全没有帮助。因此,笔者不提倡、甚至反对“教程”式的教程。

同源策略 (Same-origin policy)

同源策略 (Same-origin policy) 是一项关键的安全机制,会限制从某一个 源 (Origin) 加载的文档或脚本,与来自其它源的资源之间的交互。

一个网络 (web) 上的资源 (或者说内容, content) 通常由 URL 来唯一标识,而该资源的 “源 (Origin)",由其 URL 中的 scheme (即协议, protocol)、hostname (即域名, domain) 和端口号 (port) 三者所定义。三者必须全部相同,才能视作是同一个源。

浏览器在加载渲染显示网页时,会遵循同源策略。

同源策略可以帮助隔离开潜在的有害文档,减少可能的攻击手段 (attack vectors)。比如,这样可以使得攻击者无法利用钓鱼网站访问并转发用户登入的其它网站下的信息、用户所在内网环境中的资源,等等。

可以注意区分这种方式和 XSS 攻击的不同之处。

现代浏览器对于直接打开的计算中的文件 (即 file:/// 类型的 URL),一般将其视作来自于 “不透明源 (opaque origins)"。

“不透明 (opaque)” 指的是 “对该资源的上下文不可见”;笔者认为可将其理解为 “不可得知”。

也就是说,在浏览器中打开某一文件时,如果打开的文件引用了同目录下的其他文件,或者请求了其它资源,由于无法判断是否来自同一源,因此均会因违反同源策略而加载失败。有些浏览器会将同一目录及其子目录下的文件视作来自同一源,但这样做具有暴露用户信息的风险,比如当用户下载文件到默认目录并打开时,文件便有机会获知同一目录下其它文件的情况。

跨源网络访问

TL;DR: 简单来说,我们在浏览器中访问一个站点下的页面时,除了在页面上通过 嵌入 使用外部字体、外部 CSS 样式表,或者直接展示图片、媒体或者 <iframe>,而并不能 读取 来自其它源的内容进行进一步使用,除非其它源同意该源这么做。
举个例子,假如某个站点的资源或者 API 不想被其它站点直接在页面上获取并使用,那么同源策略会在默认条件下限制这种情况;相反,如果站点期望其它站点能够直接在页面上加载并使用自己的数据,则需要通过 CORS (参见后文) 进行相应的配置。

同源策略会限制不同源之间的交互,比如使用 fetch() 或者 <img> 标签时。通常来说,这些交互可以分为 3 类:

  • 跨源 (亦称 “跨域”) 写出 (writes) 通常被允许,比如外链 (links),页面重定向(redirects) 或者表单提交。其中一些 HTTP 请求需要事先经由 preflight 请求允许。
  • 跨源 嵌入 (embedding) 通常被允许。(请参见后续具体实例。)
  • 跨源 读取 (reads) 通常不被允许,但是由于 嵌入 的存在,常常也能一定程度上完成这一类交互。比如,所嵌入图像的维度是可以被读取的,所嵌入的脚本也会被执行(如 JSONP,所请求的脚本会调用某一个已经存在的函数,借以传递信息),甚至还可以通过某一嵌入资源是否可用来得知信息(比如,假如某网站根据用户是否登入决定资源是否可用,攻击者可借此得知用户是否在使用该网站)。

下面是一些可以跨源嵌入的资源的例子:

  • 使用 <script src="..."></script> 引入的 JavaScript。语法错误的具体错误信息仅对同源脚本可用。
  • 使用 <link rel="stylesheet" href="..."> 引用的 CSS。由于 CSS 宽松的语法规则,跨源的 CSS 需要具备正确的 Content-Type 标头。浏览器将阻止 MIME 类型不正确,且未以合法 CSS 规则开头的跨源 CSS 资源。
  • 使用 <img> 展示的图像。
  • 使用 <video><audio> 播放的媒体。
  • 使用 <object><embed> 嵌入的外部资源。
  • 使用 @font-face 应用的字体。有些浏览器允许跨源字体,有些则要求来自同源。
  • 使用 <iframe> 嵌入的任何内容。网站可以通过设置 X-Frame-Options 标头来组织跨源窗体嵌入。

使用 HTTP 中的 CORS (跨源资源共享) 可以允许跨源访问;通过 CORS,服务器可以向浏览器指明,允许从哪些 (除自身以外的) 源 (从自身) 加载内容,以此决定是否允许某个源对自己进行跨源访问。

而 (被跨源访问方) 若要阻止跨源访问:

  • 如要防止 (预期外的) 跨源写入,可以在请求中包含一个无法被猜测的口令并在服务器端校验 — 即 CSRF 口令 (CSRF token),来避免这种情况。
    CSRF (Cross-Site Request Forgery),即 “跨源请求伪造”。攻击者可通过向用户展示某个网站下的链接并诱导用户点击、展示表单并诱导用户提交,或直接通过内嵌图片(比如在电子邮件中内嵌图片)来让用户在不经意间请求指定的网址。这些情况下,用户向网站发送的请求并非出于自愿、或在知情条件下完成,而攻击者亦无须得知这些请求的内容,便可以让用户完成转账、下单等操作(如果网站没有针对这种情况进行判断的话)。

  • 如要防止资源被跨源读取,需要确保该资源不能被嵌入。因为资源被嵌入时总是会泄露出去或多或少的信息,所以保护资源不被跨源嵌入是必要的。

  • 为了防止资源被跨源嵌入,需要确保资源不能被用作 (解释为) 前文中列出的可嵌入格式。浏览器可能不会遵循 Content-Type 标头。比如,如果我们将 <script> 标签指向一个 HTML 文档,浏览器就会尝试将该 HTML 作为 JavaScript 来解析。如果该资源并不用于网站的入口,也可以使用 CSRF 口令来避免资源被他人跨源嵌入。
    比如某网站不希望自己站内的资源(如图片)被盗用,则需要进行举措来防止资源被跨源嵌入。由于浏览器在跨源请求时,默认会在 ReferrerOrigin 标头中附带当前页面的信息。服务器可以根据相关标头来判断如何返回内容。

同源策略 (Same-origin policy) 网页上还有关于跨源时的 JavaScript API 权限、跨源时数据存储相关的内容,有兴趣的读者请自行阅读,这里暂时不进行展开。

跨源资源共享 (CORS)

CORS (Cross-Origin Resource Sharing, 跨源资源共享) 是一项基于 HTTP 标头的机制,使得服务器能够向浏览器指明,可以允许浏览器从哪些除它本身之外的源加载 (它自身下的) 资源。

CORS 还依赖于浏览器的一种机制:浏览器会向跨源资源所在的服务器发送 “preflight” 请求 (译为 “预检请求”),以确认服务器是否会许可稍后的实际请求。浏览器会在 “preflight” 请求的标头中指示实际请求会使用的的 HTTP 方式,以及实际请求中使用的标头。

比方说,https://a.com 下的 JavaScript 代码想要使用 fetch() 请求 https://b.com/data.json,这就是一个跨源请求。而由于安全原因,浏览器会限制 JavaScript 发起的请求。比如 fetch()XMLHttpRequest 都遵循同源原则。这意味着,Web 应用默认只能加载使用和自身处于同一 Origin 下的数据,除非跨源的资源在标头中包含正确的 CORS 标头。

前文中已经阐述同源策略的目的和必要性,而通过 CORS (跨源资源共享) 机制,浏览器能发起安全的跨源请求、跨源加载资源。浏览器在 fetch()XMLHttpRequest 这些 API 中使用 CORS,以减少跨源请求带来的风险。

接下来将通过三种场景来演示 CORS 是如何工作的。所有示例都使用 fetch() 进行。

简单请求 (Simple requests)

TL;DR: 符合条件的跨源 简单请求 会被浏览器发出,服务器能够收到;但除非服务器允许,否则在客户端浏览器中,该请求的响应的 status, headers 以及 body 均无法 (在 JavaScript) 中读取,即 “不透明响应 (opaque requests)"。

有一些请求并不会触发 “preflight”,这些请求被称作 “简单请求” (simple requests)。这一术语来自已经不再使用的 CORS 技术规范,而新的 Fetch 技术规范 尽管定义了 CORS 但并未使用这一术语。

HTML 4.0 中的 <form> 表单元素能够向任何源提交简单请求(彼时尚无 fetch()XMLHttpRequest),因此人们撰写服务器程序时通常已经对 CSRF 作了应对处理。在这种假设下,服务器并不需要通过响应 preflight 来表明自己是否会处理某个形似表单提交的请求,因为 CSRF 带来的威胁,并不比表单提交这一行为本身所包含的威胁严重。

不过,如果网页脚本需要读取服务器响应的内容,仍然需要服务器通过 Access-Control-Allow-Origin 标头给予许可。

也就是说,跨源 简单请求 能够发出,被服务器接收到,但是如果服务器没有给出“授权”,则响应的内容无法被读取。

具体来说,一个 简单请求 需要满足以下所有条件:

  • 使用 GETHEADPOST 方法;
  • 除了用户代理 (user agent) 自动设置的标头 (如 Connection, User-Agent) 或 其它在 Fetch 规范中定义的禁用标头 外,只有 这些 Fetch 标准中定义为 CORS 安全的请求标头 允许手动设定: Accept, Accept-Language, Content-Language, Content-Type (另见后注),Range (只能包含一个简单范围区间标头值, 如 bytes=256-bytes=127-255)
  • 只允许这几种 类型/亚类型 组合作为 Content-Type 标头所指示的媒体类型: application/x-www-form-urlencoded, multipart/form-data, text-plain
  • 如果请求经由 XMLHttpRequest 发出,则 XMLHttpRequest.upload 属性返回的对象上不会注册事件监听器;也就是说,假设有一个 XMR 请求实例 xmr,则不会有代码调用 xhr.upload.addEvenListener() 添加事件监听器以监测上传过程。
  • 请求中不会使用 ReadableStream 对象。

以一个具体例子来说,假设 https://foo.example/page.html 处的内容想要获取 https://bar.other 域名下的 JSON 内容,则 foo.example 下部署的代码可能类似这样:

const fetchPromise = fetch("https://bar.other/data.json");
// 如果服务器会根据标头内容对同一 URL 响应不同内容,
// 需要记得设置对应的 Accept 标头

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data);
  });

或者使用 await 而非 promise-chaining 的写法:

const response = await fetch("https://bar.other/data.json");
const data = await response.json();
console.log(data);

这样的操作会在客户端和服务器之间的进行简单的通信,赋权将使用响应中的 CORS 标头进行。

这里对于异步 I/O 编程相关的内容不作展开,仅讨论 CORS 相关的内容。

首先,fetch() 调用后,浏览器会向服务器发送类似这样的 HTTP 报文:

GET /data.json HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example
Referrer: https://foo.example/page.html

需要留意请求中的 Origin 标头Referer 标头,前者表明请求来自 https://foo.example 这个源(用户代理会在跨源请求时带上这个标头),而后者则用来指示资源具体是从什么地址请求的。

服务器可以根据 Origin 标头来处理跨域请求,比如在响应中添加 Access-Control-Allow-Origin 标头,使得客户端可以读取该响应的内容。

也就是说,如果自己的服务涉及到 简单 跨源请求,需要在响应中设置相应的标头,以便 简单请求 的响应可以正常被读取 。如若想要访问来自不受自己控制的源的资源,且缺失相应的标头,则可以在自行将其代理后加以访问。

带有该标头的 HTTP 报文类似如下:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/json

{}

其中 Access-Control-Allow-Origin 标头的值为 * 通配符,表示允许任意源访问该资源。

如果将值设置为 https://foo.example,则表示仅允许 https://foo.example 通过 CORS 跨源访问自己的资源。

需要注意,对于 带有凭证数据的请求 (见后文),服务器必须在 Access-Control-Allow-Origin 标头中指定一个具体的 origin,而不能使用通配符 *.

在浏览器中使用 Fetch API 时,可以传入 RequestInit 对象进行一定的配置;这里只介绍几个相关的属性。
跨源请求时,对象的 mode 值默认为 cors,此时 Fetch API 便会根据 CORS 机制进行;此时如果请求为简单请求,由于不需要 preflight,会直接发出。但尽管请求会发出,如果 Access-Control-Allow-Origin 标头不满足,则会抛出错误。
需要注意,此时 Fetch API 会在请求中设置 Origin 标头。如果不希望暴露自己的来源,则可以参照下面的内容:
mode 可以设置为 no-cors(Chrome 会在上一点中提示使用这个参数,这一点比较不错),这时请求将不受 CORS 限制,并返回一个“不透明相应 (opaque response)”,但仍需要为 简单请求 — 如前所述,请求的标头会限制在 “CORS 安全请求标头”之中,且只能使用 GET, HEADPOST 方法。
注意这时的请求中不会带有 Origin 标头。
:笔者尚未测试强行设置为 * 的情况下是否仍然会返回“不透明响应”。
浏览器的请求默认会带上 referrer,网站可以通过 Referer 标头收集访客的浏览信息。如果不想暴露这个信息,则可以设置 RequestInit 对象的 referrerreferrerPolicy 参数。还可以通过设置 <meta> 标签控制 referrer policy,在请求嵌入的资源时不发送 Referer 标头。具体的选项和设置这里不多展开。
温馨提示: 如前所述,资源的跨源嵌入一般不受同源策略限制(比如引用图片),所以浏览器请求时不会发送 Origin 标头,但会带上 Referer 标头。比如,引用外链图片时,图片提供方可以根据 Referer 标头得知引用图片的站点,从而返回诸如“禁止盗图”之类的图片而非真正的图片资源。
Referer 标头的这个单词是错误拼写,而其它地方并没有这个错误。

预检请求

对于 “‘经过预检’ 的请求 (“preflighted” requests)”,浏览器会先使用 OPTIONS 方法请求跨源的资源,以便决定发出真正的请求是否安全。因为这些跨源请求可能会暴露用户数据,因此需要进行预检。

下面是一个会被进行预检的请求的例子:

const fetchPromise = fetch("https://bar.other/doc", {
  method: "POST",
  mode: "cors",
  headers: {
    "Content-Type": "text/xml",
    "X-PINGOTHER": "pingpong",
  },
  body: "<person><name>Arun</name></person>",
});

fetchPromise.then((response) => {
  console.log(response.status);
});

这个请求会创建一个 XML 格式的 body,并使用 POST 方法发送;此外,还设置了一个非标准的 HTTP 请求标头 X-PINGOTHER。类似这样的标头不是 HTTP/1.1 协议的一部分,但是对于 Web 应用程序来说一般很实用。

由于标头使用 text/xml 作为 Content-Type,并且设置了自定义标头,该请求会被进行预检。

预检请求类似这样:

OPTIONS /doc HTTP/1.1
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
...

其中,作为预检请求的一部分,Access-Control-Request-Method 用于通知服务器,实际请求将会使用 POST 方法;Access-Control-Request-Headers 则通知服务器,实际请求中的 X-PINGOTHER, Content-Type 标头会使用自定义的值。

服务器可以根据上述信息进行判断。假如服务器认为可以接受这样的请求,则返回类似这样的响应:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
...

其中 Access-Control-Allow-Methods 标头表示 POST, GET, OPTIONS 这些 HTTP 方法是可用的. 这有点类似 HTTP 405 响应时包含的 Allow 标头,只不过 Access-Control-Allow-Methods 被严格用于访问控制语境中。

此外,服务器返回值为 X-PINGOTHER, Content-TypeAccess-Control-Allow-Headers 的标头,表示允许实际请求使用这些标头。

最后 Access-Control-Max-Age 以秒为单位给出预检请求可被缓存使用的时间,其间不用再次发出预检请求。默认值为 5 秒。当下,事件最长可为 86400 秒。需要注意不同的浏览器会有自己的最大间隔值,假如 Access-Control-Max-Age 超过了该值,则优先使用浏览器自己的值。

预检请求完成后,浏览器便会进行真正的请求:

POST /doc HTTP/1.1
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF8
Origin: https://foo.example
...

服务器返回:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin

对于其中每个标头的具体作用和含义,这里不过多展开,可以访问上述的链接,或在 MDN 查询对应的标头。

需要注意,并不是所有的浏览器都支持跟随预检后实际请求的重定向。如果预检后的实际请求请求发生了重定向,部分浏览器会产生错误提示。最初,CORS 规定了需要这样做,但后续的更改不再具备这个要求。有些浏览器没有跟进这项变动,仍然遵循最开始的行为要求。

如果要支持这些未跟进的浏览器,除了修改服务端代码,使之不产生重定型,或更换为无需 preflight 的 简单请求 外,也可以在本地先使用 简单请求 得到重定向后的 URL,再向该地址请求。

但若请求包含 Authorization 标头(这也会触发 preflight),则上述的本地处理方式不再可用。

包含凭据信息的请求

CORS 和 fetch() 或 XMLHttpRequest 有意思的一项能力是,它发送 “包含凭据信息的 (credentialed)” 请求时,会考虑 HTTP cookies 以及 HTTP 认证信息。

默认情况下,浏览器中通过 fetch() 或 XMLHttpRequest 发出的请求中,不会包含凭据信息。

如果需要在 fetch() 发出的请求中包含凭据,则将 credentials 选项设为 "include"

如果需要在 XMLHttpRequest 发出的请求中包含凭据,则将 XMLHttpRequest.withCredentials 属性设为 true

假设 https://foo.example 下的内容,所请求的 https://bar.other 下的内容会设置 Cookies。

const url = "https://bar.other/resources/credentialed-content/";

const request = new Request(url, { credentials: "include" });

const fetchPromise = fetch(request);
fetchPromise.then((response) => console.log(response));

注意代码中将 credentials 选项设置为了 “include”。这是一个 简单 GET 请求,并不用进行预检,但是浏览器会拒绝任何不包含 Access-Control-Allow-Credentials: true 标头的响应,也不会使这些响应的内容对请求源可用。

浏览器和服务器之间的通信可能如下所示:

GET /resources/credentialed-content/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: https://foo.example/examples/credential.html
Origin: https://foo.example
Cookie: pageAccess=2

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

[text/plain payload]

尽管请求的 Cookie 标头中,含有指向 https://bar.other 下内容的 cookie,但如果 bar.other 没有像示例中那样,在响应中包含值为 true 的 Access-Control-Allow-Credentials 标头,则该响应会被忽略,也不会为请求源可用。

预检请求与凭据信息

CORS 预检请求在任何时候都不应包含凭据信息。如果实际请求需要包含凭据信息,则服务器对预检请求的响应中需要指明 Access-Control-Allow-Credentials: true

对于凭据相关的请求,服务器不能使用 * 通配符,必须显式指明允许的源、标头、HTTP 方法等。


Last modified on 2024-08-01