2

情景复现

某天正式环境有用户反馈某页面操作没有任何响应,SRE接收到反馈后,对问题分析复现,复现步骤如下:

用户登录商家工作台后复制页签,开启了两个页签,其中一个页签退出登录,另一个页签点击操作

另外,SRE还收集了控制台输出错误信息:

image-20221014140243977.png

image-20221014140322670.png

问题分析

根据报错信息来看,明显提示重定向后的请求跨域了。当时我认为设置了Loacation标头的Http 302重定向响应,浏览器地址栏会接着访问重定向后的链接,不应该存在同源策略的限制。但实际情况并不是想象中那般,为了解决自己的疑惑,结合场景重新分析一遍跨域问题。

什么情况下需要 CORS

这份 cross-origin sharing standard 允许在下列场景中使用跨站点 HTTP 请求:

Request Type

请求类型有Fetch/XHRJSCSSImgMediaFontDocWS (WebSocket)Wasm (WebAssembly)Manifestother(此处未列出的任何其他类型),从chrome网络面板可以筛选查看。

根据 cross-origin sharing standard ,可知数以Doc类型的地址栏请求、form表单请求不会受同源策略限制,<script src="url"></script><link href=""></link>也不会受同源策略限制,但JavaScript脚本内部发起的fetch/ajax请求会受到同源策略的限制。

image-20221102011911184.png

如果在地址栏直接请求jscsspng资源,请求类型也是document,同样不受同源策略影响。

image-20221102014002216.png

fetch/ajax请求这类资源也不会跨域,因为CDN服务一般会设置Access-Control-Allow-Origin: *

image-20221119142418758.png

浏览器地址栏里面输入一个URL重定向会发生什么?

  • 当用户开始在地址栏中输入内容时,UI 线程询问的第一件事是“您输入的字符串是搜索的关键词(search query)还是一个URL地址?”。因为对于Chrome来说,地址栏的输入既可能是一个可以直接请求的URL,还可能是用户想在搜索引擎(例如Google)里面搜索的关键词信息,所以 UI 线程需要解析并决定是将用户输入发送到搜索引擎还是直接请求你输入的站点资源。
  • 当用户按下回车键的时候,UI线程会叫网络线程(network thread)初始化一个网络请求来获取站点的内容。这时如果网络线程收到服务器的HTTP 301重定向响应,它就会告知UI线程进行重定向,然后它会再次发起一个新的网络请求
  • 网络线程在收到HTTP响应的主体(payload)流(stream)时,在必要的情况下它会先检查一下流的前几个字节以确定响应主体的具体媒体类型(MIME Type)。如果响应的主体是一个HTML文件,浏览器会将获取的响应数据交给渲染进程(renderer process)来进行下一步的工作。如果拿到的响应数据是一个压缩文件(zip file)或者其他类型的文件,响应数据就会交给下载管理器(download manager)来处理。

注:上述流程删减了后续html文件解析渲染流程,这部分跟本文内容无关

OAuth2.0授权码登录重定向过程为什么不会出现跨域问题?

表单提交,页面跳转

登陆页面一般采用表单提交<form action="URL of page">...<form> ,将表单数据提交到指定URL的服务程序处理,并跳转到指定URL。如果想阻止表单提交,可以使用e.preventDefault();或者return false,一般在表单校验不通过时阻止提交(不会请求)。

<form action="URL of page" method="post" id="form">
    <input value="登录" type="submit" onclick="handleSubmit(event)"/> // 注意type类型
</form>

function handleSubmit(e) {
  e.preventDefault(); 
    // return false;
}

以下方式不会阻止表单提交,因为document.getElementById('form').submit()会触发表单提交,没法阻断。

<form action="URL of page" method="post" id="form">
    <input value="登录" type="button" onclick="handleSubmit(event)"/>
</form>

function handleSubmit(e) {
  e.preventDefault(); 
  document.getElementById('form').submit();
    // return false;
}

如果想要表单提交后不跳转(请求但不跳转),可以通过以下方式:

<form
  action="https://at.alicdn.com/t/font_1353866_klyxwbettba.css"
  method="get"
  id="loginForm"
  target="frameName"
>
  <input type="submit" value="submit" />
</form>
<iframe src="" frameborder="0" name="frameName"></iframe>

跳转到iframe窗口,不影响当前页签显示请求的样式内容

登录授权重定向过程

我司使用<form action="login" method="post"/>表单实现登录授权,点击“登录”通过document.getElementById('fml').submit()触发表单提交。表单提交后,渲染进程通过IPC通信告知浏览器进程导航至指定/passport/login(同源),网络线程初始化一个请求将表单数据发送给指定服务/passport/login。服务端校验账户密码正确性,若账号密码正确,网络线程会接收到服务器的HTTP 302重定向响应,它就会告知UI线程进行重定向然后它会再次发起一个新的网络请求。后续无论是客户端再根据code获取token,还是客户端根据token向受保护资源服务请求html资源都是通过地址栏重定向完成的,地址栏请求类型是document,根据 cross-origin sharing standard规定,不存在跨域问题

image-20221109105109871.png

image-20221014144202406.png

image-20221109105332531.png

image-20221014142928955.png

为什么服务端指定了响应标头允许cors请求还是跨域了呢?

复制打开两个页签,其中一个页签退出登录,另一个页签触发ajax请求时,由于接口没携带身份信息,网关服务返回Http 302重定向响应体,客户端向重定向链接发起ajax请求,发起的请求和当前页签不同源,虽然后端配置了响应标头Access-Control-Allow-Origin指定了允许cors请求的域名,但还是出现了跨域问题。

注:根据 cross-origin sharing standard规定,页面脚本发起请求类型是XHR,会有跨域限制。

具有有以下两种场景:

(1)点击”查询“,发起POST请求

查询请求POST https://ec-hwbeta.casstime.com/inquiryWeb/quote/list返回Http 302响应体,客户端向重定向链接发起ajax请求(GET https://ec-hwbeta.casstime.com/oauth2/authorization/cassmall),请求同源不会出现跨域。服务端再次返回Http 302响应体,客户端重复上面步骤,向重定向链接发起ajax请求(GET https://passport-test.casstime.com/sso/oauth/authorize),此次请求不同源,出现跨域问题。

该场景有两个疑问点:

  • 后端服务https://passport-test.casstime.com配置了响应标头Access-Control-Allow-Origin指定了允许cors请求的域名,但还是出现了跨域问题。
  • 向重定向链接GET https://passport-test.casstime.com/sso/oauth/authorize发起ajax真实请求之前会发送一个preflight预检请求。

image-20221109105450018.png

(2)点击”立即报价“领取报价单,发起GET请求

发起领取请求GET https://ec-hwbeta.casstime.com/agentBuy/seller/admin/supplierquotes/receiveinquiry,服务端返回Http 302响应体,客户端向重定向链接发起ajax请求(GET https://ec-hwbeta.casstime.com/oauth2/authorization/cassmall),请求同源不会出现跨域。服务端再次返回Http 302响应体,客户端重复上面步骤,向重定向链接发起ajax请求(GET https://passport-test.casstime.com/sso/oauth/authorize),此次请求不同源,出现跨域问题。

该场景也有两个疑问点:

  • 后端服务https://passport-test.casstime.com配置了响应标头Access-Control-Allow-Origin指定了允许cors请求的域名,但还是出现了跨域问题。
  • 发送真实请求之前并没有像场景一一样发送preflight预检请求(与第一个场景的不同点)。

image-20221109105546209.png

注:以上场景均在Chrome浏览器验证,不同浏览器对重定向实现的标准不一样

两个场景唯一的不同点在于初始请求是POST复杂请求(Content-Type: application/json),还是GET简单请求,那么复杂请求和简单请求重定向有什么区别呢?

复杂请求和简单请求重定向有什么区别?

非简单请求preflight 成功后才发送实际的请求。preflight 后的实际请求不允许重定向,否则会导致 CORS 跨域失败。

虽然在 Chrome 开发版中会对重定向后的地址再次发起 preflight,但该行为并不标准。 W3C Recommendation 中提到真正的请求返回 301, 302, 303, 307, 308 都会判定为错误:

This is the actual request. Apply the make a request steps and observe the request rules below while making the request. If the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the cache and network error steps. – W3C CORS Recommendation

Chrome 中错误信息是 Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Access to XMLHttpRequest at 'https://passport-test.casstime.com/sso/oauth/authorize?response_type=code&client_id=cassmall-alpha&state=AE5D6mbF-28uCXQekXaz3-UyauYiOfvG_e9BZH_U8NM%3D&redirect_uri=https://ec-alpha.casstime.com/login/oauth2/code/cassmall' (redirected from 'https://ec-alpha.casstime.com/inquiryWeb/quote/list') from origin 'https://ec-alpha.casstime.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

image-20221026152826208.png

对于简单请求,浏览器会跳过 preflight 直接发送真正的请求。 该请求被重定向后浏览器会直接访问被重定向后的地址,也可以跟随多次重定向。 但重定向后请求头字段 origin 会被设为 "null"(被认为是 privacy-sensitive context)。 这意味着响应头中的 Access-Control-Allow-Origin 需要是 * 或者 null字符串(该字段不允许多个值)。这就是为什么服务配置了指定了具体的Access-Control-Allow-Origin还是跨域了。

chrome中错误信息是No 'Access-Control-Allow-Origin' header is present on the requested resource.

Access to XMLHttpRequest at 'https://passport-test.casstime.com/sso/oauth/authorize?response_type=code&client_id=cassmall&state=6OxlZhoSFAacnuOSapRCCjZhtM5nAlf3JLFZt5gP9P0%3D&redirect_uri=https://ec-hwbeta.casstime.com/login/oauth2/code/cassmall' (redirected from 'https://ec-hwbeta.casstime.com/agentBuy/seller/admin/supplierquotes/receiveinquiry?inquiryId=xxx&storeId=xxx&supplierCompanyId=xxx&neededClock=xxx&acceptPlace=xxx') from origin 'https://ec-hwbeta.casstime.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

image-20221026153045016.png

即使浏览器给简单请求设置了非 简单头字段(如 DNT)时,也应当继续跟随重定向且不校验响应头的 DNT (因为它属于 User Agent Header,浏览器应当对此知情)。 参考 W3C 对简单请求的处理要求

If the manual redirect flag is unset and the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the redirect steps. – W3C CORS Recommendation

OSXChrome 的行为是合理的,即使设置了 DNT 也会直接跟随重定向。

后端服务拦截处理

后端服务通过判断请求标头Origin是否在允许的白名单中,如果在,则设置Access-Control-Allow-Origin的值为请求标头Origin

image-20221104095359177.png

场景复现与分析

场景一复现:

本地搭建3001端口服务

const http = require("http");

const whiteList = ["localhost:3000"]; // 白名单

const server = http.createServer((req, res) => {
  const origin = req.headers.origin;
  console.log(
    `url: ${req.url}, origin: ${origin}, method: ${req.method.toLowerCase()}`
  );

  if (
    whiteList.includes(
      origin.slice(
        origin.indexOf("://") + 3,
        origin.endsWith("/") ? origin.length - 1 : origin.length
      )
    )
  ) {
    res.setHeader("Access-Control-Allow-Origin", `${origin}`);
    res.setHeader(
      "Access-Control-Allow-Methods",
      "PUT, GET, POST, DELETE, OPTIONS"
    );
    res.setHeader("Access-Control-Allow-Headers", "Content-Type");
  }

  if (req.method.toLowerCase() === "options") {
    res.statusCode = 200;
    res.end();
  }

  if (req.url === "/order/detail" && req.method.toLowerCase() === "post") {
    res
      .writeHead(302, {
        Location: "http://127.0.0.1:3001/order/id",
      })
      .end();
  }

  if (req.url === "/order/id" && req.method.toLowerCase() === "get") {
    res
      .writeHead(302, {
        Location: "http://127.0.0.1:3002/order/user",
      })
      .end();
  }
});

server.listen(3001, () => {
  console.log("server is listening port 3001");
});

本地搭建3002端口服务

const http = require("http");

const whiteList = ["localhost:3000"];

const server = http.createServer((req, res) => {
  const origin = req.headers.origin;
  console.log(
    `url: ${req.url}, origin: ${origin}, method: ${req.method.toLowerCase()}`
  );

  if (
    whiteList.includes(
      origin.slice(
        origin.indexOf("://") + 3,
        origin.endsWith("/") ? origin.length - 1 : origin.length
      )
    )
  ) {
    res.setHeader("Access-Control-Allow-Origin", `${origin}`);
    res.setHeader(
      "Access-Control-Allow-Methods",
      "PUT, GET, POST, DELETE, OPTIONS"
    );
    res.setHeader("Access-Control-Allow-Headers", "Content-Type");
  }

  if (req.method.toLowerCase() === "options") {
    res.statusCode = 200;
    res.end();
  }

  if (req.url === "/order/user" && req.method.toLowerCase() === "get") {
    res.setHeader("Content-Type", "text/html; charset=utf-8");
    res.end(`<!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      <div>hello world~</div>
    </body>
    </html>`);
  }
});

server.listen(3002, () => {
  console.log("server is listening port 3002");
});

页面执行调用:

// 域名为http://localhost:3000页面脚本访问
axios.post('http://127.0.0.1:3001', {});

日志打印:

image-20221104102614969.png

image-20221104102830604.png

结论:可以看到非简单请求后的重定向请求确实会发送preflight预检请求,当从http://127.0.0.1:3001/order/id重定向到http://127.0.0.1:3002/order/user,请求标头Originnull字符串,不在白名单中,响应不会携带Access-Control-Allow-Origin,自然就跨域了。

注意:预检请求返回状态码为200并不意味着其通过了跨域检查,是否通过跨域检查主要看请求标头OriginAccess-Control-Allow-Origin是否匹配

场景二复现

本地搭建的3001端口服务中/order/detail接口改成get请求类型

if (req.url === "/order/detail" && req.method.toLowerCase() === "get") {
  res
    .writeHead(302, {
      Location: "http://127.0.0.1:3001/order/id",
    })
    .end();
}

日志打印

image-20221104150249702.png

结论:可以看到简单请求后的重定向请求不会发送预检请求,当从http://127.0.0.1:3001/order/id重定向到http://127.0.0.1:3002/order/user,请求标头Origin同样为null字符串,跨域了。

解决方案

(1)将xhr/fetch请求类型改成document请求类型

form表单请求属于document请求类型,天生不会有跨域问题,刚好可以满足我们的需求。

改成document请求类型后,业务接口响应的数据通过iframe接收,形式如下:

<form
  action="http://127.0.0.1:8080/order/detail"
  method="post"
  target="form"
>
  <span style="margin-right: 20px">
    <label for="name"></label>
    <input type="text" name="name" id="name" />
  </span>
  <span style="margin-right: 20px">
    <label for="password"></label>
    <input type="password" name="password" id="password" />
  </span>
  <input type="submit" value="提交" />
</form>
<iframe
  id="form"
  name="form"
  frameborder="0"
  style="display: none"
></iframe>

<script>
  // 获取表单请求的结果
  $("#form").load(function () {
    var text = $(this).contents().find("body").text(); //获取到的是json的字符串
    // var j = $.parseJSON(text); //json字符串转换成json对象
    console.log(text);
  });
</script>

image.png

获取到请求响应后,判断其是否为登录页,如果是,则前端重定向到登陆页。在实际项目中需要将上述方式封装成一个请求方法。

但是,改成form表单请求也存在一些不合理之处:

  • 如果重定向过程中某个链接与当前页签不同源,使用iframe就会有跨域问题,因为iframe的源必须跟当前页签的源一致;
    image.png
  • 页面绝大多数业务请求都是采用ajax请求类型,如果全盘调整为form表单请求显然不合理;
表单请求、地址栏请求时,属于document请求类型,node服务端接收到请求标头Origin: undefined(不是字符串),Java没有undefined类型,不清楚服务端接收到的是什么

image.png

(2)将null字符串加入白名单,前端拦截重定向到登录页

当跨域名重定向时,请求标头Originnull字符串,可以将null字符串加入到白名单中

const whiteList = ["localhost:3000", "null"]; // 白名单

if (
  whiteList.includes(
    origin.slice(
      origin.indexOf("://") > -1 ? origin.indexOf("://") + 3 : 0,
      origin.endsWith("/") ? origin.length - 1 : origin.length
    )
  )
) {
  res.setHeader("Access-Control-Allow-Origin", `${origin}`);
  res.setHeader(
    "Access-Control-Allow-Methods",
    "PUT, GET, POST, DELETE, OPTIONS"
  );
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}

或者

res.setHeader("Access-Control-Allow-Origin", "*");

虽然上述方式解决了跨域问题,但还存在后续问题,OAuth2.0授权登录重定向过程最后一个请求会返回text/html类型内容(登录页),但是fetch或者xhr请求接收到该类型内容并不会渲染到页签中(以下为模拟场景)。

image-20221105104641753.png

image-20221105104817004.png

可以将null字符串加入到白名单中解决掉跨域问题,然后前端拦截处理,重定向到登录页:

axios.interceptors.response.use(
  (response) => {
    /** 判断重定向后的responseURL是否为登陆页面,如果是,则重定向到登录页 */
    if (response.request.status === 200 && response.request.responseURL.includes('/order/user')) {
      window.location.href = response.request.responseURL; // 重定向到登录页
    }
    return response;
  }, 
  (error) => {}
)

但是null字符串加入到白名单,意味着弱化了同源策略的保护。

(3)不纠正跨域问题,前端直接拦截跨域响应

前端接收到跨域响应后,统一拦截重定向到登录页面。跨域会产生Network Error告错信息,并且status = 0responseURL = ""

axios.interceptors.response.use(
    (response) => {
    return response;
  }, 
  (error) => {
    if (error.message === 'Network Error' && error.request.status === 0 && error.request.responseURL === '') {
      /** 跨域重定向到登录页 */
      window.location.href = `/passport/login${window.location.hash}`;
    }
  }
)

但是,如果请求链接存在广告关键字(比如,adv-api/xxx),并且浏览器启用了广告拦截插件,则该请求同样会产生Network Error错误信息,并且status = 0responseURL = ""。无法预料是否还存在其他场景产生同样的错误,所以该方案不成熟。

XMLHttpRequest.responseURL`

只读属性 XMLHttpRequest.responseURL 返回响应的序列化 URL,如果 URL 为空则返回空字符串。如果 URL 有锚点,则位于 URL # 后面的内容会被删除。如果 URL 有重定向,responseURL 的值会是经过多次重定向后的最终 URL

  • 场景一,接口返回Http 200响应,如果有锚点,则位于 URL # 后面的内容会被删除

image-20221103111719635.png

  • 场景二,接口返回Http 303响应,但没有设置Location标头

image-20221103111616914.png

  • 场景三,接口返回Http 302响应,有设置Location标头,并且访问重定向地址成功了

image-20221103112429211.png

  • 场景四,接口返回Http 302响应体,有设置Location,但访问重定向的地址跨域了

image-20221107235012526.png

用原生XMLHttpRequest请求,可以看到重定向跨域后reponseURL=""

image-20221107235349556.png

axios封装xhr的请求会包装一个Network Error错误

axios.get('http://127.0.0.1:3001/order/detail');

image-20221108000538684.png

image-20221107235613363.png

image-20221107235808863.png

  • 场景五,接口返回Http 400响应

axios请求包装错误信息“Request failed with status code 400”,与原生XMLHttpRequest请求一样responseURL返回响应序列化的URL

image-20221108001644027.png

image-20221108001941114.png

image-20221108002109383.png

  • 场景六,接口返回Http 500响应

image-20221108002642556.png

  • 场景七,请求链接含有广告关键字,浏览器启用广告拦截插件

    // 含有广告关键字ad-api
    axios.get('/ad-api/validate/system-config/get?keyword=MIN_AD_EXPOSURE_TIME');

image.png

总结

回到文章开头提出的问题,另开一个页签,退出其中一个页签,在另一个页签访问服务,请求跨域的原因在于CORS会对重定向后的XHR请求进行同源检测,而重定向后的请求Origin为字符串'null',不在白名单中,被认为是跨域了。

至于解决方案,在不大改的情况下,除了将字符串null加入白名单,前端拦截响应判断是否跳转到登录页,还未想到其他优雅的方法,以后想到了再补充完善。

参考

彻底搞懂 HTTP 3XX 重定向状态码和浏览器重定向

重定向 CORS 跨域请求

CORS 跨域中的 preflight 请求

CORS 跨域图解

Access-Control-Allow-Origin值为通配符 "*"与使用 credentials 相悖

HTTP 的重定向


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。