Document Redirect 與 XHR Redirect區別

記得要微笑發表於2022-11-22

情景復現

某天正式環境有使用者反饋某頁面操作沒有任何響應,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 執行緒需要解析並決定是將使用者輸入傳送到搜尋引擎還是直接請求你輸入的站點資源。
  • 當使用者按下Enter鍵的時候,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)將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

為了避免跨域並且又能跳轉到登陸頁,form表單請求天生不會有跨域問題,能很好地滿足我們的需求,將fetch/xhr請求型別改成document

注:表單請求、位址列請求時,屬於document請求型別,服務端接收到請求標頭Origin: undefined(不是字串),Java沒有undefined型別,不清楚服務端接收到的是什麼
<form method="GET" action="http://127.0.0.1:3001/order/detail">
  <input type="submit" value="請求" />
</form>

image-20221119154132483.png

但是採用form表單請求有一些不合理之處,

  • 需要滿足兩種場景,有身份資訊介面響應成功,不能跳轉;沒有身份資訊介面響應重定向,需要跳轉;而使用form表單請求沒法同時相容這兩種場景;
  • 頁面請求觸發點過多,將ajax改成form表單請求顯然不可理;

可以將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) => {}
)

2)不糾正跨域問題,前端直接攔截跨域響應

前端接收到跨域響應後,統一攔截重定向到登入頁面。跨域會產生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}`;
    }
  }
)
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

  • 總結

除了跨域問題會導致status = 0responseURL = "",其他場景下responseURL都會返回具體值。因此可以根據這些條件在前端攔截跨域告警,重定向到登入頁。

參考

徹底搞懂 HTTP 3XX 重定向狀態碼和瀏覽器重定向

重定向 CORS 跨域請求

CORS 跨域中的 preflight 請求

CORS 跨域圖解

Access-Control-Allow-Origin值為萬用字元 "*"與使用 credentials 相悖

HTTP 的重定向

相關文章