深入理解 CORS (跨域资源共享)

这篇文章将会带你深入了解 CORS (Cross-Origin Resource Sharing) 的概念和设置方法,确保您的网站能在遵守同源政策的前提下正确处理跨域请求。学习如何设置 Access-Control-Allow-Methods、Access-Control-Allow-Headers、Access-Control-Allow-Origin 等 header,以及在使用 cookie 时的额外设置。

同源政策 (Same-Origin Policy)

首先我们来认识浏览器的「同源政策」。

大家应该都有用过浏览器提供的 fetch API 或 XMLHttpRequest 等方式,让我们通过 JavaScript 获取资源。常见的应用是向后端 API 获取数据再呈现在前端。

需要注意的是,用 JavaScript 通过 fetch API 或 XMLHttpRequest 等方式发起请求,必须遵守同源政策 (same-origin policy)。

什么是同源政策呢?简单地说,用 JavaScript 访问资源时,如果是同源的情况下,访问不会受到限制;

然而,在同源政策下,非同源的请求则会因为安全性的考量受到限制。浏览器会强制你遵守 CORS (Cross-Origin Resource Sharing,跨域资源访问) 的规范,否则浏览器会让请求失败。

什么是同源?

那什么情况是同源,什么情况不是呢?所谓的同源,必须满足以下三个条件:

  1. 相同的通讯协议,即 http/https

  2. 相同的域名

  3. 相同的端口

举例:下列哪个与 example.com/a.html 为同源?

跨域请求

不是同源的情况下,就会产生一个跨域 http 请求(cross-origin http请求)。

举个例子,例如我想要在 bugbug.io 的页面上显示来自 othersite.com 的数据,于是我利用浏览器的 fetch API 发送一个请求:

javascript 复制代码
try {
  fetch('https://othersite.com/data')
} catch (err) {
  console.error(err);
}

这时候就产生了一个跨域请求。而跨域请求必须遵守 CORS 的规范。

当服务器没有正确设置时,请求就会因为违反 CORS 而失败,在 Chrome DevTool 就会看到以下的经典错误:

csharp 复制代码
Access to fetch at *** from origin *** 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.

我们接下来就一起来看 CORS 到底是什么,又该如何正确地设置 CORS 吧!

什么是 CORS (Cross-Origin Resource Sharing)?

终于要进入重点了,到底什么是 CORS?

简单地说,CORS (Cross-Origin Resource Sharing) 是针对不同源的请求而定的规范,通过 JavaScript 访问非同源资源时,后端服务必须明确告知浏览器允许何种请求,只有后端服务允许的请求才能够被浏览器实际发送出去,否则就会失败。

在 CORS 的规范里面,跨域请求有分两种:「简单」的请求和非「简单」的请求。

接下来会分别解释两种请求的 CORS 分别如何运行的。

简单跨域请求

所谓的「简单」请求,必须符合下面两个条件:

  1. 只能是 HTTP GET, POST or HEAD 方法

  2. 自定义的请求header 只能是 AcceptAccept-LanguageContent-LanguageContent-Type(值只能是 application/x-www-form-urlencodedmultipart/form-datatext/plain)。细节可以看 fetch spec。

不符合以上任一条件的请求就是非简单请求。

举个例子来说,下面这个请求不是一个简单的请求:

php 复制代码
const response = await fetch('https://othersite.com/data', {
  method: 'DELETE',
  headers: {
    'Content-Type': 'application/json',
    'X-CUSTOM-HEADER': '123'
  }
});

违反简单请求的地方有三个,分别是:

  1. 他是 HTTP DELETE 方法;

  2. 他的 Content-Type 是 application/json;

  3. 他带了不合规范的 X-CUSTOM-HEADER。

Origin (来源)

首先,浏览器发送跨域请求时,会带一个 Origin header,表示这个请求的来源。

Origin 包含通讯协议、域名和端口三个部分。

所以从 https://bugbug.io 发出的往 https://othersite.com/data 的请求会像这样:

vbnet 复制代码
GET /data/
Host: othersite.com
Origin: https://shubo.io
...

Access-Control-Allow-Origin

当后端服务端收到这个跨域请求时,它可以依据「请求的来源」,即 Origin 的值,决定是否要允许这个跨域请求。如果后端服务允许这个跨域请求,它可以「授权」给这个来源的 JavaScript 访问这个资源。

授权的方法是在 response 里加上 Access-Control-Allow-Origin header:

arduino 复制代码
Access-Control-Allow-Origin: https://bugbug.io

如果后端服务允许任何来源的跨域请求,那可以直接回 *

makefile 复制代码
Access-Control-Allow-Origin: *

当浏览器收到响应时,会检查请求中的 Origin header 是否符合响应的 Access-Control-Allow-Origin header,相符的情况下浏览器就会让这个请求成功,我们也可以顺利地用 JavaScript 读取到响应;反之,则浏览器会将这个请求视为是不安全的而让它失败,即便后端服务确实收到请求也成功地响应了,但基于安全性的原因 JavaScript 中也没有办法读到响应。

JavaScript 默认可以访问的「简单」response header 有以下这些:

  • Cache-Control

  • Content-Language

  • Content-Type

  • Expires

  • Last-Modified

  • Pragma

如果要让 JavaScript 访问其他 header,后端服务端可以用 Access-Control-Expose-Headers header 设置。

vbnet 复制代码
X-MY-CUSTOM-HEADER: 123
X-MY-OTHER-CUSTOM-HEADER: 123
Access-Control-Expose-Headers: X-MY-CUSTOM-HEADER, X-MY-OTHER-CUSTOM-HEADER

一般跨域请求

非「简单」的跨域请求,例如:HTTP PUT/DELETE 方法,或是 Content-Type: application/json 等,浏览器在发送请求之前会先发送一个 「preflight请求(预检请求)」,其作用在于先询问服务器:你是否允许这样的请求?真的允许的话,我才会把请求完整发送过去。

Preflight Request (预检请求)

什么是 preflight请求呢?

Preflight 请求是一个 HTTP OPTIONS 方法,会带有两个请求 header:Access-Control-Request-MethodAccess-Control-Request-Headers

  • Access-Control-Request-Method: 非「简单」跨域请求的 HTTP 方法。

  • Access-Control-Request-Headers 非「简单」跨域请求带有的非「简单」header。

比方说我发送的非「简单」跨域请求是这样:

php 复制代码
fetch('https://othersite.com/data/', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CUSTOM-HEADER': '123'
  }
})

那我的请求header 会长得会像这样:

makefile 复制代码
POST /data/
Host: othersite.com
Origin: https://bugbug.io
Content-Type: application/json
X-MY-CUSTOM-HEADER: 123

浏览器帮我们发送的 preflight请求就会像这样:

makefile 复制代码
OPTIONS /data/
Host: othersite.com
Origin: https://bugbug.io
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-MY-CUSTOM-HEADER, Content-Type

Preflight Response

那收到 preflight请求时,后端服务该做什么呢?

后端服务必须告诉浏览器:我允许的方法和 header 有哪些。因此 后端服务的响应必须带有以下两个 header:

  • Access-Control-Allow-Methods: 允许的 HTTP 方法。

  • Access-Control-Allow-Headers: 允许的非「简单」header。

当浏览器看到跨域请求的方法和 header 都有被列在允许的方法和 header 中,就表示可以实际发送请求了!

以上面提到例子来说,如果后端服务可以接受上述的请求,后端服务的 preflight response 应该要像这样:

makefile 复制代码
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: X-MY-CUSTOM-HEADER, Content-Type

浏览器收到正确的 preflight response,表示 CORS 的验证通过,就可以送出跨域请求了!

接下来,浏览器实际帮我们送出以下的跨域请求:

makefile 复制代码
POST /data/
Host: othersite.com
Origin: https://bugbug.io
Content-Type: application/json
X-MY-CUSTOM-HEADER: 123

最后一步,后端服务还是要响应 Access-Control-Allow-Origin header。浏览器会再检查一次跨域请求的响应是否带有正确的 Access-Control-Allow-Origin header:

arduino 复制代码
Access-Control-Allow-Origin: https://bugbug.io

这一步也检查无误的话,我们的跨域请求才算正式成功喔!这时候我们才能在 JavaScript 中读取响应的内容:

javascript 复制代码
fetch('https://othersite.com/data/', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CUSTOM-HEADER': '123'
  }
})
.then(response => response.json())
.then(json => {
  console.log(json);
});

一般的 HTTP 请求会带有该域名底下的 cookie;然而,跨域请求默认是不能带 cookie 的。

为什么呢?因为带有 cookie 的请求非常强大,如果请求携带的 cookie 是 session token,那这个请求可以以你的身份做很多危险的事情,像是访问你的隐私数据、从你的银行帐户转帐等。所以浏览器端针对跨域请求的 cookie 也做了规范。

首先,请求必须要明确地标示「我要访问跨域 cookie」。使用 fetch API 和 XMLHttpRequest 的设置方法如下:

  • credentials

通过 fetch API 发送跨域请求,需要设置 credentials: 'include'

php 复制代码
fetch('https://othersite.com/data', {
  credentials: 'include'
})
  • withCredentials

通过 XMLHttpRequest 发送跨域请求,需要设置 withCredentials = true;

ini 复制代码
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('POST', 'https://othersite.com/data');

如此一来跨域请求就会携带 cookie 了!

后端服务也需要额外的设置:如果是信任的来源,响应要带有 Access-Control-Allow-Credentials header:

arduino 复制代码
Access-Control-Allow-Credentials: true

如此一来,浏览器才会将 cookie 写进该域名。

注意:如果是允许使用 cookie 的情况,Access-Control-Allow-Origin 不能用 *,必须明确标示哪些来源允许访问。 理由也是基于安全性考量,因为可以用 cookie 的情况下,通常表示会访问一些比较私人的数据,假设任何网站都能够访问这样的数据,显然是有点危险的!所以不能设为 *!

如果你偷懒地用了 Access-Control-Allow-Origin: *,就会无情地收到来自浏览器的错误:

less 复制代码
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when therequest's credentials mode is 'include'. Origin http://localhost:8080 is therefore not allowed access. Thecredentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

总结

遇到 CORS 的问题,可以归纳出这样的最佳实践:

  1. 先弄清楚是否为「简单」的跨域请求,如果是,在后端 GET/POST/HEAD 方法本身加上 Access-Control-Allow-Origin header。

  2. 如果非「简单」跨域请求,在后端 OPTIONS 加上 Access-Control-Allow-MethodsAccess-Control-Allow-Headers header。另外,在后端方法本身加上 Access-Control-Allow-Origin header。

  3. (Optional) 需要使用 cookie 的情况下,前端要加上 credentials: 'include' 或是 withCredentials 参数,后端要加上 Access-Control-Allow-Credentials header,而且 Access-Control-Allow-Origin header 不能用 *

相关推荐
YaHuiLiang2 分钟前
小微互联网公司与互联网创业公司 -- 学历之殇
前端·后端·面试
用户26124583401614 分钟前
vue学习路线(11.watch对比computed)
前端·vue.js
冬天的风滚草5 分钟前
Higress开源版 大规模 MCP Server 部署配置方案
后端
雨落倾城夏未凉5 分钟前
4.信号与槽
后端·qt
CAD老兵11 分钟前
前端 Source Map 原理与结构详解
前端
gnip14 分钟前
markdown预览自定义扩展实现
前端·javascript
大猫会长26 分钟前
mac中创建 .command 文件,执行node服务
前端·chrome
旧时光_26 分钟前
Zustand 状态管理库完全指南 - 进阶篇
前端·react.js
snakeshe101028 分钟前
深入理解useState:批量更新与非函数参数支持
前端
windliang28 分钟前
Cursor 排查 eslint 问题全过程记录
前端·cursor