如果我们向另一个网站发送 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",使用我们函数的名称作为callbackURL 参数。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-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
访问任何其他响应标头会导致错误。
要授予 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.cnAccess-Control-Allow-Methods: PATCHAccess-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。
- 标头 - 我们只能设置
AcceptAccept-LanguageContent-LanguageContent-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…)。