如果我们向另一个网站发送 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
获取数据,例如天气
-
首先,我们提前声明一个全局函数来接收数据,例如
gotWeather
。// 1. Declare the function to process the weather data function gotWeather({ temperature, humidity }) { alert(`temperature: ${temperature}, humidity: ${humidity}`); }
-
然后,我们创建一个
<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);
-
远程服务器
another.com
动态生成一个脚本,该脚本使用它希望我们接收的数据调用gotWeather(...)
。// The expected answer from the server looks like this: gotWeather({ temperature: 25, humidity: 78 });
-
当远程脚本加载并执行时,
gotWeather
运行,由于它是我们的函数,我们拥有了数据。
这可行,并且不会违反安全性,因为双方都同意以这种方式传递数据。而且,当双方都同意时,这绝对不是黑客行为。仍然有一些服务提供这种访问,因为它即使对于非常旧的浏览器也能正常工作。
一段时间后,浏览器 JavaScript 中出现了网络方法。
最初,跨域请求是被禁止的。但经过长时间的讨论,跨域请求被允许,但任何新的功能都需要服务器在特殊标头中明确允许。
安全请求
跨域请求有两种类型
- 安全请求。
- 所有其他请求。
安全请求更容易进行,所以让我们从它们开始。
如果请求满足两个条件,则该请求是安全的
- 安全方法:GET、POST 或 HEAD
- 安全标头 - 唯一允许的自定义标头是
Accept
,Accept-Language
,Content-Language
,Content-Type
的值为application/x-www-form-urlencoded
、multipart/form-data
或text/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
)或星号*
。然后响应成功,否则为错误。
浏览器在这里充当可信的中间人
- 它确保在跨域请求中发送正确的
Origin
。 - 它检查响应中是否允许
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-Encoding
和API-Key
标头。
“不安全”请求
我们可以使用任何 HTTP 方法:不仅是GET/POST
,还有PATCH
、DELETE
等。
前段时间,没有人能想象一个网页可以发出这样的请求。因此,可能仍然存在将非标准方法视为信号的 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-urlencoded
、multipart/form-data
、text/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
.
这允许未来的通信,否则会触发错误。
如果服务器将来需要其他方法和标头,则可以通过将它们添加到列表中来提前允许它们。
例如,此响应还允许 PUT
、DELETE
和其他标头。
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-urlencoded
、multipart/form-data
或text/plain
。
本质区别在于,安全请求从古代开始就可以使用<form>
或<script>
标签完成,而长时间以来,浏览器无法实现不安全请求。
因此,实际区别在于,安全请求会立即发送,并带有Origin
头,而对于其他请求,浏览器会先进行“预检”请求,以请求权限。
对于安全请求
- → 浏览器会发送带有来源的
Origin
头。 - ← 对于没有凭据的请求(默认情况下不发送),服务器应将
Access-Control-Allow-Origin
设置为*
或与Origin
相同的值
- ← 对于有凭据的请求,服务器应将
Access-Control-Allow-Origin
设置为与Origin
相同的值Access-Control-Allow-Credentials
设置为true
此外,为了允许 JavaScript 访问除Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
或Pragma
之外的任何响应头,服务器应在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
包含缓存权限的秒数。
- 然后发送实际请求,并应用之前的“安全”方案。
评论
<code>
标签,对于多行代码,请将其包装在<pre>
标签中,对于超过 10 行的代码,请使用沙箱(plnkr,jsbin,codepen…)。