浏览器跨域 CORS 协议基础知识梳理

最近老是遇到联调的时候出现跨域问题,便萌生把跨域问题理理清楚的想法
首先罗列一些基础的知识点

先贴一下官方文档

CORS 是什么

随着Web开放的程度越来越高,通过浏览器跨域获取资源的需求已经变得非常普遍。在我看来,如果Web API不能针对浏览器提供跨域资源共享的能力,它甚至就不应该被称为Web API。从另一方面来看,浏览器作为进入Internet最大的入口,是各大IT公司的必争之地,所以浏览器市场出现了种类繁多、鱼龙混杂的局面。针对这两点,我们迫切需要一种能够被各个 浏览器厂商 共同遵循的标准来对跨域资源共享作出规范,这就是由W3C指定2的CORS(Cross-Origin Resource Sharing)规范。

划重点 : 这里说的是浏览器厂商,下面我们会提到

了解 CORS 实现原理之前,先了解两个基础概念

  • 在 CORS 请求返回头中,我们会用到的ResponseHeader

    名称 描述
    Access-Control-Allow-Origin 允许跨域的域名
    Access-Control-Allow-Methods 允许跨域的http method
    Access-Control-Allow-Headers 允许携带的 request header
    Access-Control-Allow-Credentials 是否支持用户凭证
    Access-Control-Max-Age 失效时间
    Access-Control-Expose-Headers ResponseHeader中允许暴露给JavaScript的header
  • CORS 的 简单请求 和 复杂请求

    W3C的CORS规范将跨域资源请求划分为两种类型,一种被称为简单请求(Simple Request)。要弄清楚CORS规范将那些类型的跨域资源请求划分为简单请求的范畴,需要额外了解几个名称的含义,其中包括简单(HTTP)方法(Simple Method)简单(请求)报头(Simple Header)自定义请求报头(Author Request Header/Custom Request Header)

    • 什么是简单请求

      简单请求必须满足以下条件限制

      • 请求的方法METHOD只能是以下类型之一

        • HEAD
        • GET
        • POST

          CORS规范将GETHEADPOST这三个HTTP方法视为简单HTTP方法(Simple Method)

      • 请求报文中的头信息RequestHead不能超出如下限制

        • 报文中的头信息字段不能超过如下

          • Accept
          • Accept-Language
          • Content-Language
          • Last-Event-ID
          • Content-Type
        • Content-Type 的类型(值)只能是一下值之一
          • application/x-www-form-urlencoded
          • multipart/form-data
          • text/plain

            CORS规范将满足上面情况的请求头视为简单(请求)报头(Simple Header)

      CORS规范将服务如下条件的跨域资源请求划分为简单请求:请求采用简单HTTP方法,并且其自定义请求报头空或者所有自定义请求报头均为简单请求报头。之所以作如此划分是因为具有这些特性的请求不是以更新(添加、修改和删除)资源为目的,服务端对请求的处理不会导致自身维护资源的改变。

    • 什么是复杂请求

      不是简单请求的请求称之为复杂请求

CORS 的两个步骤 取得授权获取资源

CROS利用资源提供者的显式授权来决定目标资源是否应该与消费者共享。也就是说,浏览器需要取得资源提供者的授权之后才会将其提供的资源分发给消费者。那么,资源的提供者如何进行资源的授权,并将授权的结果告诉浏览器呢?
具体的实现其实很简单。CORS将资源的获取分为两个步骤 : 取得授权获取资源

  • 取得授权 预检查Preflight

    按照CORS规范的规定,浏览器应该采用一种被称为预检(Preflight)的机制来完成非简单跨域资源请求。
    浏览器会发送一个如下的请求给资源提供者

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    OPTIONS /simple-request HTTP/1.1
    Host: localhost:3000
    Connection: keep-alive
    Accept: */*
    Access-Control-Request-Method: PUT
    Access-Control-Request-Headers: content-type
    Origin: http://localhost:8000
    User-Agent: Chrome/98
    Referer: http://localhost:8000/
    Accept-Encoding: gzip, deflate, br
    Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7

    其中有三个字段有明确的含义

    • Origin 代表申请授权(申请跨域)的来源,即请求页面所在的站点
    • Access-Control-Request-Method 申请跨域请求采用的HTTP METHOD
    • Access-Control-Request-Headers 申请跨域请求携带的自定义报头列表

    浏览器会根据资源提供者返回的头(ResponseHeader)中的字段来判断是否获得跨域授权:

    • Access-Control-Allow-Origin
      表明当前资源可以跨域的站点,
      如果包含当前请求的 Origin 时,则代表当前站点可以使用此跨域资源。
      如果返回 Access-Control-Allow-Origin: * 则代表此资源是一个公共资源,所有站点都可以使用。

    • Access-Control-Allow-Methods
      表明当前资源可以跨域使用的HTTP METHOD
      如果包含当前预检的Access-Control-Request-Method值,则代表当前站点可以使用此HttpMethod跨域获取资源
      如果返回Access-Control-Allow-Methods: * 则代表所有HttpMethod都可以。

    • Access-Control-Allow-Headers
      表明当前资源可以跨域使用的自定义报头列表,
      如果包含当前预检的Access-Control-Request-Headers值,则代表当前站点可以使用这些自定义报头列表跨域获取资源

    预检请求并不是返回成功(httpCode:200)就代表通过了,浏览器会根据返回的(ResponseHeader)判断是否能获得授权,如果有上面的三个条件有任何一个没有满足,都会被浏览器视为取得授权,抛出我们常见的跨域错误。

    比如:

    • No Access-Control-Allow-Origin header is present on the requested resource
      这个就是应为没有返回Access-Control-Allow-Origin这个header
    • Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response
      这个就是Access-Control-Allow-Headers没返回或者没有包含content-type
    • Method OPTIONS is not allowed by Access-Control-Allow-Methods in preflight response.
      这个就是因为Access-Control-Allow-Methods没返回或者没有包含OPTIONS

    这些都是预检返回200,浏览器判断授权失败,抛出的跨域错误

  • 获取资源

    当预检返回被浏览器验证通过后(取得授权),才会向发送普通请求(非跨域请求)一样,发送请求

模拟

假设我们的浏览器地址为 http://localhost:8000,
后台服务为 http://localhost:3000

如果我们要请求 PUT /simple-request-custom-content-type 这个接口,那么浏览器会先发送的 预检 大概是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
OPTIONS /simple-request-custom-content-type HTTP/1.1
Host: localhost:3000
Connection: keep-alive
Accept: */*
Access-Control-Request-Method: GET
Access-Control-Request-Headers: content-type
Origin: http://localhost:8000
User-Agent: Chrome/98.0.4758.80
Referer: http://localhost:8000/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7

要想预检通过,返回的ResponseHeader大概是这样的
1
2
3
4
5
6
7
8
HTTP/1.1 200 OK                                                
Content-Type: Application/json
Access-Control-Allow-Origin: http://localhost:8000 // 允许 http://localhost:8000 访问
Access-Control-Allow-Headers: content-type // 允许使用 content-type 这个header
Access-Control-Request-Method: PUT // 允许使用 PUT 方法
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

这个时候浏览器就会判定为(取得授权),继续请求了。

简单请求的不同之处

对于简单跨域资源请求来说,浏览器将两个步骤(取得授权获取资源)合二为一,由于不涉及到资源的改变,所以不会带来任何副作用(Side Effect)。

模拟

假设我们的浏览器地址为http://localhost:8000,
后台服务为http://localhost:3000

如果我们要请求GET /simple-request这个接口,那么浏览器会发送的请求大概是下面这样的:

1
2
3
4
5
6
7
8
9
GET /simple-request HTTP/1.1
Host: localhost:3000
Connection: keep-alive
User-Agent: Chrome
Accept: */*
Origin: http://localhost:8000
Referer: http://localhost:8000/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7

这时候要想跨域通过,返回的ResponseHeader应该是这样的
1
2
3
4
5
6
7
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:8000 // 允许 http://localhost:8000 访问
Access-Control-Request-Method: GET // 允许使用 PUT 方法
Date: Thu, 24 Feb 2022 14:25:46 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

并一起返回我们的数据ResponseBody,但是,如果返回的ResponseHeader不合法,即不能判定为取得授权
浏览器就会抛出跨域错误了,并且我们在Network中也是看不到返回的结果的。

有一些后端知识的小伙伴应该知道,其实这时候后端的接口已经完成调用了,只是浏览器没有展示而已
这也是很多后端小伙伴会困惑的问题:为什么我都返回了,还跨域。我的Postman都能调通。我真不知道是什么原因!!!

再看两个案例

假设我们的浏览器地址为http://localhost:8000,
后台服务为http://localhost:3000

  • 简单请求 Simple Method

    1
    2
    3
    4
    5
    6
    7
    8
    9
    GET /simple-request HTTP/1.1
    Host: localhost:3000
    Connection: keep-alive
    User-Agent: Chrome/98.0.4758.80
    Accept: */*
    Origin: http://localhost:8000
    Referer: http://localhost:8000/
    Accept-Encoding: gzip, deflate, br
    Accept-Language: zh-CN,zh;q=0.9

    只要返回头里包含Access-Control-Allow-Origin: http://localhost:8000就可以了

  • 这是一个简单请求 Simple Method + Simple Header

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    GET /simple-request HTTP/1.1
    Host: localhost:3000
    Connection: keep-alive
    User-Agent: Chrome/98.0.4758.80
    Content-Type: multipart/form-data
    Accept: */*
    Origin: http://localhost:8000
    Referer: http://localhost:8000/
    Accept-Encoding: gzip, deflate, br
    Accept-Language: zh-CN,zh;q=0.9

    只要返回头里包含Access-Control-Allow-Origin: http://localhost:8000就可以了

  • Simple Method + Custom Header

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    GET /unstandard-content-type HTTP/1.1
    Host: localhost:3000
    Connection: keep-alive
    User-Agent: Chrome/98.0.4758.80
    Content-Type: application/json
    Accept: */*
    Origin: http://localhost:8000
    Referer: http://localhost:8000/
    Accept-Encoding: gzip, deflate, br
    Accept-Language: zh-CN,zh;q=0.9

    由于Content-Typeapplication/json,这不是一个简单请求。会触发预检
    预检要想通过,ResponseHead中要包含

    1
    2
    Access-Control-Allow-Origin: http://localhost:8000             // 允许 http://localhost:8000 访问
    Access-Control-Request-Headers: Content-Type // 允许使用 Content-Type 头

到这里,大致的 CORS 流程就完了,还有几个小的不常用的知识点,再补充下

Access-Control-Max-Age

如果每次都预检Preflight的话,那不是跨域请求会增加服务器负担呢?这就是Access-Control-Max-Age的作用了

预检响应结果会被浏览器缓存,在Access-Control-Max-Age报头设定的时间内,缓存的结果将被浏览器用户进行授权检验,所以在此期间不会再有预检请求发送。

Access-Control-Allow-Credentials

在默认情况下,利用XMLHttpReuqest发送的Ajax请求不会携带用户凭证相关的敏感信息,这里的用户凭证类型包括CookieHTTP-Authentication报头等。如果需要用户凭证附加到Ajax请求上,需要将XMLHttpReuqest的withCredentials 属性设置为True

对于CORS来说,是否支持用户凭证也是授权检验的一个环节。换句话说,只有在服务端显式声明支持用户凭证的情况下,携带了用户凭证的请求才会被认为是有效的。在W3C的CORS规范来说,服务端利用响应报头Access-Control-Allow-Credentials来表明自身是否支持用户凭证。

Access-Control-Expose-Headers

资源提供者可以通过设置Access-Control-Expose-Headers这个报头对响应报头进行授权。
具体来说,如果我们后端返回的ResponseHeader有一些自定义的Custom Header

1
2
debugId: 123
abPlan: A

但是我们却在Network中无法看到这些Header,而在Postmancurl中却可以看到,那是因为浏览器屏蔽了这些header,需要添加如下header

1
Access-Control-Expose-Headers: debugId,abPlan

总结

跨域问题并不是说接口没有异常就可以了,很多时候是因为返回的ResponseHeader不能通过浏览器预检

关于CORS大概的也就讲完了,我只是一个前端小菜鸡,若有不正之处,还请多多指教
最后,放一个demo链接

划重点

跨域问题,千万不要被 : “Postmancurl能调通” 这句话误导啊,这不是接口掉不通的问题,是接口返回头不符合规范的问题啊!!!

参考文章
[CORS:跨域资源共享] W3C的CORS Specification
W3C指定的CORS(Cross-Origin Resource Sharing)规范介绍