这篇文章将会带你深入了解 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,跨域资源访问) 的规范,否则浏览器会让请求失败。
什么是同源?
那什么情况是同源,什么情况不是呢?所谓的同源,必须满足以下三个条件:
-
相同的通讯协议,即 http/https
-
相同的域名
-
相同的端口
举例:下列哪个与 example.com/a.html 为同源?
-
example.com/b.html (⭕️)
-
example.com/c.html (❌,不同协议)
-
subdomain.example.com/d.html (❌,不同域名)
-
example.com:8080/e.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 分别如何运行的。
简单跨域请求
所谓的「简单」请求,必须符合下面两个条件:
-
只能是 HTTP GET, POST or HEAD 方法
-
自定义的请求header 只能是
Accept
、Accept-Language
、Content-Language
或Content-Type
(值只能是application/x-www-form-urlencoded
、multipart/form-data
或text/plain
)。细节可以看 fetch spec。
不符合以上任一条件的请求就是非简单请求。
举个例子来说,下面这个请求不是一个简单的请求:
php
const response = await fetch('https://othersite.com/data', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CUSTOM-HEADER': '123'
}
});
违反简单请求的地方有三个,分别是:
-
他是 HTTP DELETE 方法;
-
他的 Content-Type 是 application/json;
-
他带了不合规范的 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-Method
和 Access-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);
});
跨域请求的 Cookie
一般的 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 的问题,可以归纳出这样的最佳实践:
-
先弄清楚是否为「简单」的跨域请求,如果是,在后端 GET/POST/HEAD 方法本身加上
Access-Control-Allow-Origin
header。 -
如果非「简单」跨域请求,在后端 OPTIONS 加上
Access-Control-Allow-Methods
及Access-Control-Allow-Headers
header。另外,在后端方法本身加上Access-Control-Allow-Origin
header。 -
(Optional) 需要使用 cookie 的情况下,前端要加上
credentials: 'include'
或是withCredentials
参数,后端要加上Access-Control-Allow-Credentials
header,而且Access-Control-Allow-Origin
header 不能用*
。