CORS 是一个W3C标准,全称是跨域资源共享(Cross-Origin Resource Sharing),也有译为“跨源资源共享”的。
它允许浏览器向跨源服务器,发出 XMLHttpRequest(XHR) 或 Fetch API 跨域 HTTP 请求,从而克服了同源使用的限制。
本文内容主要参考于跨域资源共享 CORS 详解和 MDN 相关文档。
一、简介
CORS 是 HTTP 的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。
CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于 IE10。
整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附件的头信息,有时还会多处一次附件的请求,但用户不会有感觉。
因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口(响应报文包括了正确的 CORS 响应头),就可以跨源通信。
二、同源安全策略
同源策略是一个重要的安全策略,它用于限制一个 Origin 的文档或它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。
如果两个 URL 的协议(Protocol)、主机(Host)、端口(Port,如果有指定的话)都相同的话,那么这两个 URL 是同源的,否则是不同源的。
下表给出了与 URL http://store.company.com/dir/page.html
的源进行对比的示例:
URL | 结果 | 原因 |
---|---|---|
http://store.company.com/dir2/other.html | 同源 | 只有路径不同。 |
http://store.company.com/dir/inner/another.html | 同源 | 只有路径不同。 |
https://store.company.com/secure.html | 不同源 | 协议不同。 |
http://store.company.com:81/dir/etc.html | 不同源 | 端口不同(http:// 默认端口是 80)。 |
http://news.company.com/dir/other.html | 不同源 | 主机不同。 |
关于 IE 浏览器的特例,其中差异点是不规范的,其他浏览器未做出支持。
出于安全性,浏览器限制脚本内发起的跨域 HTTP 请求,例如常见的 XHR、Fetch API 都遵循同源策略。
三、两种请求
浏览器将 CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
若满足下述所有条件,则该请求可视为“简单请求”:
- 请求方法是以下三种之一:
HEAD
GET
POST
- HTTP 的头信息不超出以下几种字段:
Accept
Accept-Language
Content-Language
Content-Type
DPR
Downlink
Save-Data
Viewport-Width
Width
- Content-Type 的值仅限于以下三种之一
text/plain
multipart/form-data
application/x-www-form-urlencoded
- 请求中的任意
XMLHttpRequestUpload
对象均没有注册任何事件监听器。- 请求中没有使用
ReadableStream
对象。
凡是不同时满足以上条件,均属于非简单请求。
注意: 这些跨站点请求与浏览器发出的其他跨站点请求并无二致。如果服务器未返回正确的响应首部,则请求方不会收到任何数据。因此,那些不允许跨站点请求的网站无需为这一新的 HTTP 访问控制特性担心。
三、简单请求
一个简单的 XHR 请求示例:
客户端(Client):
// Client 客户端(http://192.168.1.105:8080)
function xhrRequest() {
// 创建 xhr 对象
const xhr = new XMLHttpRequest()
// 通过 onreadystatechange 事件捕获请求状态的变化
// 必须在 open 之前指定该事件,否则无法接收 readyState 0 和 1 的状态
xhr.onreadystatechange = () => {
console.log(xhr.status)
console.log(xhr.readyState)
}
// 捕获错误
xhr.onerror = err => {
console.log('error:', err)
}
// 初始化请求
xhr.open('GET', 'http://192.168.1.105:7701/config', true)
// 发送请求
xhr.send(null)
}
xhrRequest()
服务端(Server):
// Server 服务端(通过 node 命令即可启动,http://192.168.1.105:7701)
const http = require('http')
const port = 7701
const allowedOrigin = 'http://192.168.1.105:8080' // 这个是我本地客户端的 Origin(源)
const server = http.createServer((request, response) => {
if (request.headers.origin === allowedOrigin) {
response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
}
if (request.url === '/config') {
response.end(JSON.stringify({ name: 'Frankie', age: 20 }))
}
})
server.listen(port)
发起 XHR HTTP 请求,我们可以在控制台看到请求报文和响应报文。(View source)
1. 基本流程
对于简单请求,浏览器直接发出 CORS 请求。具体来说,就在头信息之中,增加一个 Origin
字段。
通过以上示例发起 HTTP GET
请求,请求报文如下:
GET /config HTTP/1.1
Host: 192.168.1.105:7701
Connection: keep-alive
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
Accept: */*
Origin: http://192.168.1.105:8080
Referer: http://192.168.1.105:8080/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
上面的头信息中,Origin
字段用来说明,本次请求来自哪个源(协议 + 主机 + 端口)。服务器根据这个值,绝对是否同意这次请求。
如果 Origin
指定的源,不在许可范围内,服务器会返回一个正确的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin
字段(相见下文),就知道出错了,从而抛出一个错误,被 XMLHttpRequest
的 onerror
回调函数捕获。注意,这种错误无法通过状态识别,因为 HTTP 回应的状态码有可能是 200
。
在上述服务端示例中,我们指定的源就是 http://192.168.1.105:8080
,因此可以正常发起请求,并拿到接口响应数据:{"name":"Frankie","age":20}
。
假设,我们将服务端示例许可的 Origin
去掉:
// if (request.headers.origin === allowedOrigin) {
// response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
// }
我们再次发起 /config
请求的时候,可以看到控制台抛出跨域错误了:
需要注意的是,尽管无论从控制台看到的报错、还是 Network 选项卡的 Failed to load response data,都可以知道我们的请求失败了。但......其实是这样的:就本文服务端示例而言,即使是跨域请求,也是会返回响应数据的,只是 JavaScript 脚本获取到的结果是“失败”而已。
那不对啊,如果服务器正常返回数据,而前端却失败了,那信息不对称啊,中间商赚差价了?怎么回事呢?原因是浏览器在中间搞鬼,那浏览器在中间扮演了什么角色呢?
当发起 CORS 请求时,浏览器首先会在请求报文上自动加上
Origin
的字段(它的值由当前页面的 Protocol + Host + Port 部分组成),到达服务端之后,会做出相应的处理并返回数据。由于服务端并没有给响应报文的头部设置Access-Control-Allow-Origin
(前面说把这块注释掉了),自然浏览器接收到的响应报文中就不含Access-Control-Allow-Origin
,当浏览器就判断到请求报文与响应报文的Origin
不相等,此时它不会将服务器的响应数据 JavaScript 脚本,即我们的 XHR 对象无法得到服务器的响应数据,并且会触发 XHR 对象的onerror
事件,让脚本来捕获错误。所以,我们就看到请求失败了。(这种情况下,可以通过抓包工具查看服务器返回的数据)还有,只有在
Origin
指定的源在许可范围内,服务器响应报文才会多出这些Access-Control-Allow-Origin
、Access-Control-Allow-Credentials
、Access-Control-Expose-Headers
头信息字段。(可从客户端 Network 选项卡观察到)
以下与 CORS 请求相关的头信息字段,都以 Access-Control-
开头。
Access-Control-Allow-Origin: http://10.16.4.226:8080
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Date
- Access-Control-Allow-Origin
这个字段是必须的。它的值要么是请求时 Origin
字段的值,要么是一个 *
,表示接受任意的源(域名)的请求。
- Access-Control-Allow-Credentials
这个字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS 请求之中。设为 true
,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为 true
,如果服务器不要浏览器发送 Cookie,删除该字段即可。
// Client
xhr.withCredentials = true
// Server
response.setHeader('Access-Control-Allow-Credentials', true)
当客户端携带 Cookie 发起请求,同时服务端允许携带 Cookie 的情况下,可以看到请求报文包含了 Cookie 信息:
Cookie: username=Frankie
- Access-Control-Expose-Headers
这个字段可选。CORS 请求时,XMLHttpRequest
对象的 getResponseHeader()
方法只能拿到 6 个基本字段: Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
。如果想要拿到其他字段,就必须在 Access-Control-Expose-Headers
里面指定。(但由于 W3C 的限制,不指定的情况下,客户端获取到的值可能为 null
)
例如,服务端将 Access-Control-Expose-Headers
指定为 "Date,Access-Control-Allow-Origin"
,我们可以通过 XMLHttpRequest 对象的 getResponseHeader() 方法获取对应字段的值。
// Client
xhr.onreadystatechange = () => {
if (xhr.status === 200 && xhr.readyState === 4) {
console.log(xhr.getResponseHeader('Cache-Control')) // null
console.log(xhr.getResponseHeader('Content-Language')) // null
console.log(xhr.getResponseHeader('Content-Type')) // null
console.log(xhr.getResponseHeader('Expires')) // null
console.log(xhr.getResponseHeader('Last-Modified')) // null
console.log(xhr.getResponseHeader('Pragma')) // null
console.log(xhr.getResponseHeader('Date')) // "Sun, 06 Jun 2021 14:34:34 GMT"
console.log(xhr.getResponseHeader('Access-Control-Allow-Origin')) // "http://192.168.1.105:8080"
console.log(xhr.getResponseHeader('X-Custom-Header')) // Error: Refused to get unsafe header "X-Custom-Header"
}
}
// Server
if (request.headers.origin === allowedOrigin) {
response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
response.setHeader('Access-Control-Allow-Credentials', true)
response.setHeader('Access-Control-Expose-Headers', 'Date,Access-Control-Allow-Origin')
}
如果连接未完成,响应中不存在报文项,或者被 W3C 限制,则返回
null
。假设如上示例,自定义头信息X-Custom-Header
字段,且服务端未对其进行设置,则会抛出错误:Refused to get unsafe header "X-Custom-Header"
2. withCredentials 属性
上面说到,CORS 请求默认不发送 Cookie 和 HTTP 认证信息。如果要把 Cookie 发送到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials
字段。
Access-Control-Allow-Credentials: true
另一方面,开发者必须在 AJAX 请求中打开 withCredentials
属性。
xhr.withCredentials = true
否则,即使服务器同意发送 Cookie,浏览器也不会发送。或者服务器要求设置 Cookie,浏览器也不会处理。
但是,如果省略 withCredentials
设置,有的浏览器还是会一起发送 Cookie。这时,可以显示关闭 withCredentials
。
xhr.withCredentials = false
需要注意的是,如果要发送 Cookie,Access-Control-Allow-Origin
就不能设为 *
(否则会抛出如下错误),必须指定明确的、与请求网页一致的域名。同时,Cookie 依然遵循同源策略,只有用服务器域名设置的 Cookie 才会上传,其他域名的 Cookie 并不会上传,且(跨源)原网页代码中的 document.cookie
也无法读取服务器域名下的 Cookie。
四、非简单请求
调整一下示例:
// Client 客户端
function xhrRequest() {
// 创建 xhr 对象
const xhr = new XMLHttpRequest()
// 通过 onreadystatechange 事件捕获请求状态的变化
xhr.onreadystatechange = () => {
console.log(xhr.status)
console.log(xhr.readyState)
if (xhr.status === 200 && xhr.readyState === 4) {
console.log(xhr.getResponseHeader('Date'))
console.log(xhr.getResponseHeader('Access-Control-Allow-Origin'))
}
}
// 捕获错误
xhr.onerror = err => {
console.log('error:', err)
}
// 初始化请求
xhr.open('PUT', 'http://192.168.1.105:7701/config', true)
// 设置自定义头部(必须在 open 之后)
xhr.setRequestHeader('X-Custom-Header', 'foo')
// 发送请求
xhr.send(null)
}
xhrRequest()
// Server 服务端
const http = require('http')
const port = 7701
const allowedOrigin = 'http://192.168.1.105:8080' // 这个是我本地客户端的 Origin(源)
const server = http.createServer((request, response) => {
if (request.headers.origin === allowedOrigin) {
response.setHeader('Access-Control-Allow-Origin', allowedOrigin)
response.setHeader('Access-Control-Allow-Credentials', true)
response.setHeader('Access-Control-Allow-Methods', 'PUT')
response.setHeader('Access-Control-Allow-Headers', 'X-Custom-Header')
response.setHeader('Access-Control-Expose-Headers', 'Date,Access-Control-Allow-Origin')
}
if (request.url === '/config') {
response.end(JSON.stringify({ name: 'Frankie', age: 20 }))
}
})
server.listen(port)
1. 预检请求
非简单请求时那种对服务器有特殊要求的请求,比如请求方法是 PUT
或 DELETE
,或者 Content-Type
字段的类型是 application/json
。
非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为预检请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。
上面代码中,HTTP 请求的方法是 PUT
,并且发送一个自定义头信息 X-Custom-Header
。
浏览器发现,这是一个非简单请求,就自动发出一个“预检请求”,要求服务器确认可以这样请求。下面是这个“预检请求”的 HTTP 头信息。
OPTIONS /config HTTP/1.1
Host: 192.168.1.105:7701
Connection: keep-alive
Accept: */*
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: x-custom-header
Origin: http://192.168.1.105:8080
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
Sec-Fetch-Mode: cors
Referer: http://192.168.1.105:8080/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
“预检请求”用的请求方法是 OPTIONS
,表示这个请求是用来询问的。头信息里面,关键字是 Origin
,表示请求来自哪个源。
除了 Origin
字段,“预检请求”的头信息包括两个特殊字段。
Access-Control-Request-Method
这个字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上面示例是 PUT。Access-Control-Request-Headers
这个字段是一个逗号,
分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上面示例是X-Custom-Header
。
Tips:预检请求可以在调试器 Network 选项卡中查看,如下图:
2. 预检请求的回应
服务器收到“预检请求”以后,检查了 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 字段以后,确认允许跨域请求,就可以做出回应。
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://192.168.1.105:8080
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Expose-Headers: Date,Access-Control-Allow-Origin
Date: Sun, 06 Jun 2021 15:27:27 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 27
上面的 HTTP 回应中,关键的是 Access-Control-Allow-Origin
字段,表示 http://192.168.1.105:8080
可以请求数据。该字段也可以设为 *
,表示同意任意的跨源请求。(本示例携带了 Cookie 信息,不可设为 *
)
Access-Control-Allow-Origin: *
如果服务器否定了“预检请求”,会返回一个正常的 HTTP 回应,但是没有任何的 CORS 相关的头信息字段。这是,浏览器就认定,服务器不同意预检请求,因此触发一个错误,被 XMLHttpRequest
对象的 onerror
回调函数捕获。
如果将服务端的 Access-Control-Allow-Headers
注释掉:
// response.setHeader('Access-Control-Allow-Headers', 'X-Custom-Header')
控制台会打印出如下的报错信息:
服务器回应的其他 CORS 相关字段如下:
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
Access-Control-Allow-Methods
这个字段是必须的,它的值是逗号,
分隔的一个字符串,表明服务器支持的所有跨域请求。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次“预检请求”。(本示例仅设置了一个PUT
方法。)Access-Control-Allow-Headers
如果浏览器请求包括Access-Control-Request-Headers
字段,则Access-Control-Allow-Headers
字段是必须的,它也是一个逗号,
分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在“预检请求”的字段。Access-Control-Allow-Credentials
这个字段与简单请求时的含义相同。Access-Control-Max-Age
这个字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 20 天(1728000 秒),即允许缓存该条回应 1728000 秒(即 20 天),在此期间,不用发出另一条预检请求。
3. 浏览器的正常请求和回应
一旦服务器通过了“预检请求”,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个 Origin
头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin
头信息字段。
下面是“预检请求”之后,浏览器的正常 CORS 请求的请求报文。
PUT /config HTTP/1.1
Host: 192.168.1.105:7701
Connection: keep-alive
Content-Length: 0
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1
X-Custom-Header: foo
Accept: */*
Origin: http://192.168.1.105:8080
Referer: http://192.168.1.105:8080/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: username=Frankie
上面的头信息的 Origin
字段是浏览器自动添加的。
下面是服务器正常的响应报文。
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://192.168.1.105:8080
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Expose-Headers: Date,Access-Control-Allow-Origin
Date: Sun, 06 Jun 2021 15:40:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 27
上面的头信息中,Access-Control-Allow-Origin
字段是每次回应都必定包含的。
五、与 JSONP 的比较
CORS 与 JSONP 的使用目的相同,但是比 JSONP 更强大。
JSONP 只支持 GET
请求,CORS 支持所有类型的 HTTP 请求。JSONP 的优势在于支持老式浏览器,以及可以向不支持 CORS 的网站请求数据。
六、其他
以下内容可能与 CORS 无关,不想看可跳过。
1. HTTP headers 之 Referer
Referer
请求头包含了当前请求页面的来源页面的地址,即表示当前页面是通过此来源页面里的链接进入的。服务端一般使用 Referer
请求头识别访问来源,可能会以此进行统计分析、日志记录以及缓存优化等。
注意,如果直接在地址栏输入 URL 访问某网页,这时 Referer
为空。
例如,上面示例中请求 /config
接口的网页地址是 http://192.168.1.105:8080/#/about
,从请求报文可以大致看到 Origin
与 Referer
的区别。
Origin: http://192.168.1.105:8080
Referer: http://192.168.1.105:8080/
Origin
Origin
的值可以置空或者是
,即协议 + 主机 + 端口。其中主机是指域名或者 IP 地址;端口号可选,缺省时为服务的默认端口(例如 HTTP 请求默认端口是:// [: ] 80
,HTTPS 请求默认端口是443
)Referer
当前页面被链接而至的前一页面的绝对路径或者相对路径。不包含 URL fragments (例如"#section"
) 和 userinfo (例如"https://username:[email protected]/foo/bar/"
中的"username:password"
)。
上面的解释比较官方,换句话说就是从哪个页面链接过来的。例如,我从 GitHub 仓库点击跳转至文章,可以从 HTML 的请求报文看到:
Host: www.jianshu.com
Referer: https://github.com/toFrankie/csscomb-mini
假设有一个需求是统计从 GitHub 访问的访问量,那么就可以通过 Referer
来实现。也常用于防盗链等。
需要注意的是,
Referer
的正确英语拼法是 referrer。由于早期 HTTP 规范的拼写错误,为保持向下兼容就将错就错了。例如 DOM Level 2、Referrer Policy 等其他网络技术的规范曾试图修正此问题,使用正确拼法,导致目前拼法并不统一。
七、参考
- CORS Glossary
- 跨域资源共享 CORS 详解(阮一峰)