zl程序教程

您现在的位置是:首页 >  其他

当前栏目

CORS

2023-04-18 14:27:58 时间

CORS(https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-

CN%2Fdocs%2FGlossary%2FCORS) 是一个W3C标准,全称是 跨域资源共享 (Cross-Origin Resource

Sharing),也有译为“跨源资源共享”的。

它允许浏览器向跨源服务器,发出

XMLHttpRequest(https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-

CN%2Fdocs%2FWeb%2FAPI%2FXMLHttpRequest)(XHR) 或 [Fetch

API](https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-

CN%2Fdocs%2FWeb%2FAPI%2FFetch_API) 跨域 HTTP

请求,从而克服了同源(https://links.jianshu.com/go?to=http%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2016%2F04%2Fsame-

origin-policy.html)使用的限制。

本文内容主要参考于跨域资源共享 CORS 详解和 MDN 相关文档。

一、简介

CORS 是 HTTP 的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于 IE10。

整个 CORS 通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 AJAX 通信没有差别,代码完全一样。浏览器一旦发现

AJAX 请求跨源,就会自动添加一些附件的头信息,有时还会多处一次附件的请求,但用户不会有感觉。

因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口(响应报文包括了正确的 CORS 响应头),就可以跨源通信。

二、同源安全策略

同源策略是一个重要的安全策略,它用于限制一个 Origin 的文档或它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

**如果两个 URL

的协议(https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-

CN%2Fdocs%2FGlossary%2FProtocol)(Protocol)、主机(https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-

CN%2Fdocs%2FGlossary%2FHost)(Host)、端口(https://links.jianshu.com/go?to=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-

US%2Fdocs%2FGlossary%2FPort)(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字段(相见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror`

回调函数捕获。注意,这种错误无法通过状态识别,因为 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- OriginAccess-Control-Allow-CredentialsAccess-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-ControlContent-LanguageContent-TypeExpires、`Last-

ModifiedPragma。如果想要拿到其他字段,就必须在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. 预检请求

非简单请求时那种对服务器有特殊要求的请求,比如请求方法是 PUTDELETE,或者 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-Methodundefined这个字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上面示例是 PUT。
  • Access-Control-Request-Headersundefined这个字段是一个逗号 , 分隔的字符串,指定浏览器 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-Methodsundefined这个字段是必须的,它的值是逗号 , 分隔的一个字符串,表明服务器支持的所有跨域请求。注意,返回的是 所有 支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次“预检请求”。(本示例仅设置了一个 PUT 方法。)
  • Access-Control-Allow-Headersundefined如果浏览器请求包括 Access-Control-Request-Headers 字段,则 Access-Control-Allow-Headers 字段是必须的,它也是一个逗号 , 分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在“预检请求”的字段。
  • Access-Control-Allow-Credentialsundefined这个字段与简单请求时的含义相同。
  • Access-Control-Max-Ageundefined这个字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 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,从请求报文可以大致看到

OriginReferer 的区别。

Origin: http://192.168.1.105:8080
Referer: http://192.168.1.105:8080/
  • OriginundefinedOrigin 的值可以置空或者是 <scheme>://<host> [:<port>],即协议 + 主机 + 端口。其中主机是指域名或者 IP 地址;端口号可选,缺省时为服务的默认端口(例如 HTTP 请求默认端口是 80,HTTPS 请求默认端口是 443
  • Refererundefined当前页面被链接而至的前一页面的绝对路径或者相对路径。不包含 URL fragments (例如 "#section") 和 userinfo (例如 "https://username:password@example.com/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 等其他网络技术的规范曾试图修正此问题,使用正确拼法,导致目前拼法并不统一。