动态

详情 返回 返回

Document Redirect 與 XHR Redirect區別 - 动态 详情

情景復現

某天正式環境有用户反饋某頁面操作沒有任何響應,SRE接收到反饋後,對問題分析復現,復現步驟如下:

用户登錄商家工作台後複製頁籤,開啓了兩個頁籤,其中一個頁簽退出登錄,另一個頁籤點擊操作

另外,SRE還收集了控制枱輸出錯誤信息:

image-20221014140243977.png

image-20221014140322670.png

問題分析

根據報錯信息來看,明顯提示重定向後的請求跨域了。當時我認為設置了Loacation標頭的Http 302重定向響應,瀏覽器地址欄會接着訪問重定向後的鏈接,不應該存在同源策略的限制。但實際情況並不是想象中那般,為了解決自己的疑惑,結合場景重新分析一遍跨域問題。

什麼情況下需要 CORS

這份 cross-origin sharing standard 允許在下列場景中使用跨站點 HTTP 請求:

  • 出於安全性,瀏覽器限制腳本內發起的跨源 HTTP 請求。例如,XMLHttpRequest 和 Fetch API 遵循同源策略(XHRfetch請求類型)。這意味着使用這些 APIWeb 應用程序只能從加載應用程序的同一個域請求 HTTP 資源,除非響應報文包含了正確 CORS 響應頭。
  • Web 字體 (CSS 中通過 @font-face 使用跨源字體資源),因此,網站就可以發佈 TrueType 字體資源,並只允許已授權網站進行跨站調用。
  • WebGL 貼圖
  • 使用 drawImageImages/video 畫面繪製到 canvas
  • 來自圖像的 CSS 圖形 (en-US)

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 的重定向

user avatar zhoumo_62382eba4b454 头像 chongdianqishi 头像 zxl20070701 头像 emonzan 头像 wei4118268_5df1ce05d1877 头像 zxbing0066 头像 melodyne 头像
点赞 7 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.