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

简单说,当我们访问一个网站时,浏览器会对源地址的不同部分(协议://域名:端口)做检查。比如防止利用它源的存储信息(Cookies...)做不安全的用途。

跨域 CORS

但凡被浏览器识别为不同源,浏览器都会认为是跨域,默认是不允许的。

比如:试图在 http://127.0.0.1:4000 中,请求 http://127.0.0.1:3000 的资源会出现如下错误:

这也是前端 100% 在接口调试中会遇到的问题。

同源和跨域的判断规则

当前浏览器访问地址: http://domain/url

URL 结果 原因
http://domain/other 同源 地址不同
http://domain2 跨域 域名不同
http://domain:8080 跨域 端口不同
https://domain 跨域 协议不同

简单请求和复杂请求

相信都会在浏览器的 Network 中看到两个同样地址的请求,有没有想过这是为什么呢?这是因为在请求中,会分为 简单请求 复杂请求

简单请求 :满足如下条件的,将不会触发跨域检查:

  • 请求方法为: GET POST HEAD
  • 请求头: Accept Accept-Language Content-Language Content-Type
  • 其中 Content-Type 限定为 :text/plain、multipart/form-data、application/x-www-form-urlencoded

    我们可以更改同源规则,看下如下示例:

    http://127.0.0.1:4000/ 下,请求 http://127.0.0.1:3000 不同端口的地址

    域名不同,这已经跨域了。但由于请求方法为 GET ,符合 简单请求 ,请求将正常工作。

    复杂请求 :不满足简单请求的都为复杂请求。在发送请求前,会使用 options 方法发起一个 预检请求(Preflight) 到服务器,以获知服务器是否允许该实际请求。

    模拟一个跨域请求:

    // 端口不同,content-type 也非限定值
    axios.post(
      'http://127.0.0.1:3000/test/cors',
        headers: {
          'content-type': 'application/json',
    

    能看到在请求之前浏览器会事先发起一个 Preflight 预检请求

    这个 预检请求 的请求方法为 options,同时会包含 Access-Control-xxx 的请求头:

    当然,此时服务端没有做跨域处理(示例使用 express 起的服务,预检请求默认响应 200),就会出现浏览器 CORS 的错误警告。

    如何解决跨域

    对于跨域,前端再熟悉不过,百度搜索能找到一堆解决方法,关键词不是 JSONP,或者添加些 Access-Control-XXX 响应头。

    本篇将详细说下后一种方式,姑且称为:服务端解决方案。

    为 options 添加响应头

    express 举例,首先对 OPTIONS 方法的请求添加这些响应头,它将根据告诉浏览器根据这些属性进行跨域限制:

    app.use(function (req, res, next) {
      if (req.method == 'OPTIONS') {
        res.setHeader('Access-Control-Allow-Origin', '*');
        res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, OPTIONS');
        res.setHeader('Access-Control-Allow-Headers', 'content-type');
        res.status(200).end();
    

    如果你不对 预检接口 做正确的设置,那么后续一切都是徒劳。

    打个比方:如果 Access-Control-Allow-Methods 只设置了 POST,如果客户端请求方法为 PUT,那么最终会出现跨域异常,并会指出 PUT 没有在预检请求中的 Access-Control-Allow-Methods 出现:

    所以,以后读懂跨域异常对于正确的添加服务端响应信息非常重要。另外:GET、POST、HEAD 属于简单请求的方法,所以即使不在 Access-Control-Allow-Methods 定义也不碍事(如果不对请指出)

    正式的跨域请求

    随后对我们代码发出的请求额外添加跨域响应头(这需要和前面的预检接口一致)

    if (req.method == 'OPTIONS') {
      //...
    } else {
      // http://127.0.0.1:3000/test/cors
      res.setHeader('Access-Control-Allow-Origin', '*');
      next();
    

    最后能看到我们等请求正常请求到了:

    对于跨域请求头的说明

    上例出现了我们经常见到的三个:Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers

    参考 cors 库,另外还有其他用于预检请求的响应头:

    头属性作用
    Access-Control-Allow-Origin判断源地址(协议://域名:端口)
    Access-Control-Allow-Methods限定方法(GET,HEAD,PUT,PATCH,POST,DELETE)
    Access-Control-Allow-Headers限定请求头(content-type)
    Access-Control-Max-Age预检请求的缓存时间(单位为秒,-1 不缓存)
    Access-Control-Expose-Headers授权客户端能获取到的响应头
    Access-Control-Request-Headers客户端生成的请求头
    Access-Control-Allow-Credentials限定客户端可以携带敏感信息
    Vary定义可变化的头,防止浏览器缓存

    下面将对上面这些头做个说明。

    Access-Control-Allow-Origin

    预检请求正常请求 告知浏览器被允许的源。支持通配符“*”,但不支持以逗号“,”分割的多源填写方式。

    如果尝试些多个域名,则会出现如下错误:

    Response to preflight request doesn't pass access control check: The 'Access-Control-Allow-Origin' header contains multiple values 'aaa,bbb', but only one is allowed.

    另外,也不建议 Access-Control-Allow-Origin 以通配符方式定义,这样会增加安全隐患,最好以请求方的 origin 来赋值。

    const origin = req.headers.origin;
    res.setHeader('Access-Control-Allow-Origin', origin || '*');
    // 因为会随着客户端请求的 Origin 变化,所以标识 Vary,让浏览器不要缓存
    res.setHeader('Vary', 'Origin');
    

    Access-Control-Allow-Methods

    被允许的 Http 方法,按照需要填写,支持多个,例如: GET , HEAD , PUT , PATCH , POST , DELETE

    由于判断 简单请求 之一的 HTTP 方法默认为 GETPOSTHEAD ,所以这些即使不在 Access-Control-Allow-Methods 约定,浏览器也是支持的。

    比如:如果服务端定义 PUT 方法,而客户端发送的方法为 DELETE,则会出现如下错误:

    res.setHeader('Access-Control-Allow-Methods', 'PUT');
    

    Method DELETE is not allowed by Access-Control-Allow-Methods in preflight response.

    Access-Control-Allow-Headers

    预检接口 告知客户端允许的请求头。

    简单请求 约定的请求头默认支持: AcceptAccept-LanguageContent-LanguageContent-Typetext/plain、multipart/form-data、application/x-www-form-urlencoded

    如果客户端的请求头不在定义范围内,则会报错:

    Request header field abc is not allowed by Access-Control-Allow-Headers in preflight response.

    需要将此头调整为:

    res.setHeader('Access-Control-Allow-Headers', 'content-type, abc');
    

    Access-Control-Max-Age

    定义 预检接口 告知客户端允许的请求头可以缓存多久。

    默认时间规则:

  • 在 Firefox 中,上限是 24 小时 (即 86400 秒)。
  • 在 Chromium v76 之前, 上限是 10 分钟(即 600 秒)。
  • 从 Chromium v76 开始,上限是 2 小时(即 7200 秒)。
  • Chromium 同时规定了一个默认值 5 秒。
  • 如果值为 -1,表示禁用缓存,则每次请求前都需要使用 OPTIONS 预检请求。
  • 比如设置为 5 秒后,客户端在第一次会发送 预检接口 后,5 秒内将不再发送 预检接口

    res.setHeader('Access-Control-Max-Age', '5');
    

    Access-Control-Allow-Credentials

    跨域的请求,默认浏览器不会将当前地址的 Cookies 信息传给服务器,以确保信息的安全性。如果有需要,服务端需要设置 Access-Control-Allow-Credentials 响应头,另外客户端也需要开启 withCredentials 配置。

    // 客户端请求
    axios.post(
      'http://127.0.0.1:3000/test/cors',
        headers: {
          'content-type': 'application/json',
          abc: '123',
        withCredentials: true,
    
    // 所有请求
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    

    需要注意的是,Access-Control-Allow-Origin 不能设置通配符“*”方式,会出现如下错误:

    这个 Access-Control-Allow-Origin 必须是当前页面源的地址。

    Access-Control-Expose-Headers

    Access-Control-Allow-Credentials 类似,如果服务端有自定义设置的请求头,跨域的客户端请求在响应信息中是接收不到该请求头的。

    // 服务端
    res.setHeader('def', '123');
    
    axios
      .post(
        'http://127.0.0.1:3000/test/cors',
          headers: {
            'content-type': 'application/json',
            abc: '123',
          withCredentials: true,
      .then((data) => {
        console.log(data.headers.def); //undefined
    

    需要在服务端设置 Access-Control-Expose-Headers 响应头,并标记哪些头是客户端能获取到的:

    res.setHeader('Access-Control-Expose-Headers', 'def');
    res.setHeader('def', '123');
    

    Access-Control-Request-Headers

    我试了半天没找到 Access-Control-Request-Headers 的使用示例,其实它是根据当前请求的头拼接得到的。

    如果客户端的请求头为:

    "content-type": "application/json", "abc": "123", "xyz": "123",

    那么浏览器最后会在 预检接口 添加一个 Access-Control-Request-Headers 的头,其值为:abc,content-type,xyz。然后服务端再根据 Access-Control-Allow-Headers 告诉浏览器服务端的请求头支持说明,最后浏览器判断是否会有跨域错误。

    另外,对于服务端也需要针对 Access-Control-Request-HeadersVary 处理:

    res.setHeader('Vary', 'Origin' + ', ' + req.headers['access-control-request-headers']);
    

    如此,对于跨域及其怎么处理头信息会有个基本的概念。希望在遇到类似问题能有章法的解决,而非胡乱尝试。

  • 私信