2022 年 10 月 18 日

获取:跨域请求

如果我们向另一个网站发送 fetch 请求,它可能会失败。

例如,让我们尝试获取 http://example.com

try {
  await fetch('http://example.com');
} catch(err) {
  alert(err); // Failed to fetch
}

如预期的那样,获取失败。

这里的主要概念是 - 一个域名/端口/协议三元组。

跨域请求 - 那些发送到另一个域(即使是子域)或协议或端口的请求 - 需要来自远程端的特殊标头。

该策略称为“CORS”:跨域资源共享。

为什么需要 CORS?简史

CORS 的存在是为了保护互联网免受恶意黑客的攻击。

说真的,让我们简要地回顾一下历史。

多年来,来自一个网站的脚本无法访问另一个网站的内容。

这个简单而强大的规则是互联网安全的基础。例如,来自网站 hacker.com 的恶意脚本无法访问用户在网站 gmail.com 的邮箱。人们感到安全。

当时,JavaScript 也没有任何特殊方法来执行网络请求。它只是一个用来装饰网页的玩具语言。

但网页开发者要求更多功能。人们发明了各种技巧来绕过这个限制,并向其他网站发出请求。

使用表单

与另一个服务器通信的一种方法是向其提交一个 <form>。人们将其提交到 <iframe> 中,只是为了留在当前页面,就像这样

<!-- form target -->
<iframe name="iframe"></iframe>

<!-- a form could be dynamically generated and submitted by JavaScript -->
<form target="iframe" method="POST" action="http://another.com/…">
  ...
</form>

因此,即使没有网络方法,也可以向另一个网站发出 GET/POST 请求,因为表单可以将数据发送到任何地方。但由于禁止从另一个网站访问 <iframe> 的内容,因此无法读取响应。

准确地说,实际上有一些技巧可以做到这一点,它们需要在 iframe 和页面中使用特殊的脚本。因此,与 iframe 的通信在技术上是可能的。现在没有必要详细说明,让这些恐龙安息吧。

使用脚本

另一个技巧是使用 script 标签。脚本可以有任意 src,可以是任意域名,例如 <script src="http://another.com/…">。可以从任何网站执行脚本。

如果一个网站,例如 another.com,打算公开这种访问方式的数据,那么就会使用所谓的“JSONP(带填充的 JSON)”协议。

以下是它的工作原理。

假设我们在我们的网站上需要从 http://another.com 获取数据,例如天气

  1. 首先,我们提前声明一个全局函数来接收数据,例如 gotWeather

    // 1. Declare the function to process the weather data
    function gotWeather({ temperature, humidity }) {
      alert(`temperature: ${temperature}, humidity: ${humidity}`);
    }
  2. 然后,我们创建一个 <script> 标签,其 src="http://another.com/weather.json?callback=gotWeather",使用我们函数的名称作为 callback URL 参数。

    let script = document.createElement('script');
    script.src = `http://another.com/weather.json?callback=gotWeather`;
    document.body.append(script);
  3. 远程服务器 another.com 动态生成一个脚本,该脚本使用它希望我们接收的数据调用 gotWeather(...)

    // The expected answer from the server looks like this:
    gotWeather({
      temperature: 25,
      humidity: 78
    });
  4. 当远程脚本加载并执行时,gotWeather 运行,由于它是我们的函数,我们拥有了数据。

这可行,并且不会违反安全性,因为双方都同意以这种方式传递数据。而且,当双方都同意时,这绝对不是黑客行为。仍然有一些服务提供这种访问,因为它即使对于非常旧的浏览器也能正常工作。

一段时间后,浏览器 JavaScript 中出现了网络方法。

最初,跨域请求是被禁止的。但经过长时间的讨论,跨域请求被允许,但任何新的功能都需要服务器在特殊标头中明确允许。

安全请求

跨域请求有两种类型

  1. 安全请求。
  2. 所有其他请求。

安全请求更容易进行,所以让我们从它们开始。

如果请求满足两个条件,则该请求是安全的

  1. 安全方法:GET、POST 或 HEAD
  2. 安全标头 - 唯一允许的自定义标头是
    • Accept,
    • Accept-Language,
    • Content-Language,
    • Content-Type 的值为 application/x-www-form-urlencodedmultipart/form-datatext/plain

任何其他请求都被认为是“不安全的”。例如,使用 PUT 方法或 API-Key HTTP 标头的请求不符合限制。

本质区别在于,安全请求可以使用 <form><script> 进行,无需任何特殊方法。

因此,即使是非常旧的服务器也应该准备好接受安全请求。

相反,使用非标准标头或例如方法 DELETE 的请求无法通过这种方式创建。很长一段时间,JavaScript 无法执行此类请求。因此,旧服务器可能会假设此类请求来自特权来源,“因为网页无法发送它们”。

当我们尝试发出不安全请求时,浏览器会发送一个特殊的“预检”请求,询问服务器 - 它是否同意接受此类跨域请求,还是不接受?

并且,除非服务器通过标头明确确认,否则不会发送不安全请求。

现在我们将深入了解细节。

安全请求的 CORS

如果请求是跨域的,浏览器始终会向其添加 Origin 标头。

例如,如果我们从 https://javascript.js.cn/page 请求 https://anywhere.com/request,则标头将如下所示

GET /request
Host: anywhere.com
Origin: https://javascript.js.cn
...

如您所见,Origin 标头包含确切的来源(域/协议/端口),没有路径。

服务器可以检查Origin,如果同意接受此类请求,则在响应中添加一个特殊的标头Access-Control-Allow-Origin。该标头应包含允许的来源(在本例中为https://javascript.js.cn)或星号*。然后响应成功,否则为错误。

浏览器在这里充当可信的中间人

  1. 它确保在跨域请求中发送正确的Origin
  2. 它检查响应中是否允许Access-Control-Allow-Origin,如果存在,则允许 JavaScript 访问响应,否则会失败并出现错误。

以下是一个允许服务器响应的示例

200 OK
Content-Type:text/html; charset=UTF-8
Access-Control-Allow-Origin: https://javascript.js.cn

响应标头

对于跨域请求,默认情况下,JavaScript 只能访问所谓的“安全”响应标头

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

访问任何其他响应标头会导致错误。

要授予 JavaScript 访问任何其他响应标头的权限,服务器必须发送Access-Control-Expose-Headers标头。它包含一个逗号分隔的列表,其中包含应使其可访问的非安全标头名称。

例如

200 OK
Content-Type:text/html; charset=UTF-8
Content-Length: 12345
Content-Encoding: gzip
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: https://javascript.js.cn
Access-Control-Expose-Headers: Content-Encoding,API-Key

有了这样的Access-Control-Expose-Headers标头,脚本就可以读取响应的Content-EncodingAPI-Key标头。

“不安全”请求

我们可以使用任何 HTTP 方法:不仅是GET/POST,还有PATCHDELETE等。

前段时间,没有人能想象一个网页可以发出这样的请求。因此,可能仍然存在将非标准方法视为信号的 Web 服务:“这不是浏览器”。他们在检查访问权限时可以考虑这一点。

因此,为了避免误解,任何“不安全”的请求(在过去无法完成)都不会立即由浏览器发出。首先,它会发送一个初步的“预检”请求,以请求许可。

预检请求使用OPTIONS方法,没有主体,并且有三个标头

  • Access-Control-Request-Method标头包含不安全请求的方法。
  • Access-Control-Request-Headers标头提供其不安全 HTTP 标头的逗号分隔列表。
  • Origin标头告诉请求来自哪里。(例如https://javascript.js.cn

如果服务器同意提供服务,则应以空主体、状态 200 和标头进行响应

  • Access-Control-Allow-Origin必须是*或请求的来源,例如https://javascript.js.cn,才能允许它。
  • Access-Control-Allow-Methods必须包含允许的方法。
  • Access-Control-Allow-Headers必须包含允许的标头列表。
  • 此外,Access-Control-Max-Age 标头可以指定缓存权限的秒数。因此,浏览器无需为满足给定权限的后续请求发送预检请求。

让我们以跨域 PATCH 请求(此方法通常用于更新数据)为例,逐步了解其工作原理。

let response = await fetch('https://site.com/service.json', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'API-Key': 'secret'
  }
});

请求不安全的理由有三个(一个就够了)

  • 方法 PATCH
  • Content-Type 不属于以下之一:application/x-www-form-urlencodedmultipart/form-datatext/plain
  • “不安全”的 API-Key 标头。

步骤 1(预检请求)

在发送此类请求之前,浏览器会自行发送一个类似于以下内容的预检请求。

OPTIONS /service.json
Host: site.com
Origin: https://javascript.js.cn
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type,API-Key
  • 方法:OPTIONS
  • 路径 - 与主请求完全相同:/service.json
  • 跨域特殊标头
    • Origin - 源头。
    • Access-Control-Request-Method - 请求方法。
    • Access-Control-Request-Headers - “不安全”标头的逗号分隔列表。

步骤 2(预检响应)

服务器应以状态 200 和以下标头进行响应

  • Access-Control-Allow-Origin: https://javascript.js.cn
  • Access-Control-Allow-Methods: PATCH
  • Access-Control-Allow-Headers: Content-Type,API-Key.

这允许未来的通信,否则会触发错误。

如果服务器将来需要其他方法和标头,则可以通过将它们添加到列表中来提前允许它们。

例如,此响应还允许 PUTDELETE 和其他标头。

200 OK
Access-Control-Allow-Origin: https://javascript.js.cn
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400

现在,浏览器可以看到 PATCH 位于 Access-Control-Allow-Methods 中,Content-Type,API-Key 位于列表 Access-Control-Allow-Headers 中,因此它会发送主请求。

如果存在带有秒数的 Access-Control-Max-Age 标头,则预检权限将在给定时间内被缓存。上面的响应将被缓存 86400 秒(一天)。在此时间范围内,后续请求不会导致预检。假设它们符合缓存的允许值,它们将直接发送。

步骤 3(实际请求)

预检成功后,浏览器现在会发出主请求。此处的过程与安全请求相同。

主请求具有 Origin 标头(因为它跨域)

PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascript.js.cn

步骤 4(实际响应)

服务器不应该忘记在主响应中添加Access-Control-Allow-Origin。成功的预检请求并不能免除这一点。

Access-Control-Allow-Origin: https://javascript.js.cn

然后,JavaScript 就可以读取主服务器响应。

请注意

预检请求发生在“幕后”,对 JavaScript 是不可见的。

JavaScript 只会获取主请求的响应,或者如果服务器没有权限,则会获取错误。

凭据

默认情况下,由 JavaScript 代码发起的跨域请求不会携带任何凭据(cookie 或 HTTP 身份验证)。

这对 HTTP 请求来说并不常见。通常,对http://site.com 的请求会伴随着来自该域的所有 cookie。另一方面,由 JavaScript 方法发起的跨域请求是一个例外。

例如,fetch('http://another.com') 不会发送任何 cookie,即使是那些 (!) 属于another.com 域的 cookie。

为什么?

这是因为带有凭据的请求比没有凭据的请求强大得多。如果允许,它会赋予 JavaScript 以用户身份操作并使用其凭据访问敏感信息的全部权限。

服务器真的信任该脚本吗?那么它必须使用额外的标头显式地允许带有凭据的请求。

要在fetch 中发送凭据,我们需要添加选项credentials: "include",如下所示

fetch('http://another.com', {
  credentials: "include"
});

现在fetch 会将来自another.com 的 cookie 与对该站点的请求一起发送。

如果服务器同意接受带有凭据的请求,它应该在响应中添加标头Access-Control-Allow-Credentials: true,除了Access-Control-Allow-Origin

例如

200 OK
Access-Control-Allow-Origin: https://javascript.js.cn
Access-Control-Allow-Credentials: true

请注意:对于带有凭据的请求,Access-Control-Allow-Origin 禁止使用星号*。如上所示,它必须在那里提供确切的来源。这是一个额外的安全措施,以确保服务器真正知道它信任谁来发出此类请求。

总结

从浏览器的角度来看,跨域请求有两种类型:“安全”和所有其他类型。

“安全”请求必须满足以下条件

  • 方法:GET、POST 或 HEAD。
  • 标头 - 我们只能设置
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type 为值application/x-www-form-urlencodedmultipart/form-datatext/plain

本质区别在于,安全请求从古代开始就可以使用<form><script>标签完成,而长时间以来,浏览器无法实现不安全请求。

因此,实际区别在于,安全请求会立即发送,并带有Origin头,而对于其他请求,浏览器会先进行“预检”请求,以请求权限。

对于安全请求

  • → 浏览器会发送带有来源的Origin头。
  • ← 对于没有凭据的请求(默认情况下不发送),服务器应将
    • Access-Control-Allow-Origin设置为*或与Origin相同的值
  • ← 对于有凭据的请求,服务器应将
    • Access-Control-Allow-Origin设置为与Origin相同的值
    • Access-Control-Allow-Credentials设置为true

此外,为了允许 JavaScript 访问除Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma之外的任何响应头,服务器应在Access-Control-Expose-Headers头中列出允许的响应头。

对于不安全请求,会在发出请求之前先发出一个“预检”请求

  • → 浏览器会向同一 URL 发送一个OPTIONS请求,并带有以下头
    • Access-Control-Request-Method包含请求的方法。
    • Access-Control-Request-Headers列出不安全的请求头。
  • ← 服务器应以状态码 200 响应,并带有以下头
    • Access-Control-Allow-Methods包含允许的方法列表
    • Access-Control-Allow-Headers包含允许的头列表
    • Access-Control-Max-Age包含缓存权限的秒数。
  • 然后发送实际请求,并应用之前的“安全”方案。

任务

重要性:5

如你所知,存在 HTTP 头Referer,它通常包含发起网络请求的页面的 URL。

例如,当从https://javascript.js.cn/some/url获取http://google.com时,头看起来像这样

Accept: */*
Accept-Charset: utf-8
Accept-Encoding: gzip,deflate,sdch
Connection: keep-alive
Host: google.com
Origin: https://javascript.js.cn
Referer: https://javascript.js.cn/some/url

如你所见,RefererOrigin都存在。

问题

  1. 既然Referer包含更多信息,为什么还需要Origin
  2. RefererOrigin可能不存在,或者不正确吗?

我们需要Origin,因为有时Referer不存在。例如,当我们从 HTTPS 获取 HTTP 页面(从更安全的页面访问不太安全的页面)时,就没有Referer

内容安全策略可能会禁止发送Referer

正如我们将在后面看到,fetch 具有阻止发送 Referer 甚至允许更改 Referer(在同一站点内)的选项。

根据规范,Referer 是一个可选的 HTTP 头。

正是因为 Referer 不可靠,所以发明了 Origin。浏览器保证跨域请求的 Origin 正确。

教程地图

评论

在评论之前请阅读...
  • 如果您有任何改进建议,请 提交 GitHub 问题 或拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入少量代码,请使用 <code> 标签,对于多行代码,请将其包装在 <pre> 标签中,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)。