情景復現
某天正式環境有使用者反饋某頁面操作沒有任何響應,SRE
接收到反饋後,對問題分析復現,復現步驟如下:
使用者登入商家工作臺後複製頁籤,開啟了兩個頁籤,其中一個頁簽退出登入,另一個頁籤點選操作
另外,SRE
還收集了控制檯輸出錯誤資訊:
問題分析
根據報錯資訊來看,明顯提示重定向後的請求跨域了。當時我認為設定了Loacation
標頭的Http 302
重定向響應,瀏覽器位址列會接著訪問重定向後的連結,不應該存在同源策略的限制。但實際情況並不是想象中那般,為了解決自己的疑惑,結合場景重新分析一遍跨域問題。
什麼情況下需要 CORS
?
這份 cross-origin sharing standard 允許在下列場景中使用跨站點 HTTP
請求:
- 出於安全性,瀏覽器限制指令碼內發起的跨源 HTTP 請求。例如,
XMLHttpRequest
和 Fetch API 遵循同源策略(XHR
和fetch
請求型別)。這意味著使用這些API
的Web
應用程式只能從載入應用程式的同一個域請求HTTP
資源,除非響應報文包含了正確CORS
響應頭。 Web
字型 (CSS
中透過@font-face
使用跨源字型資源),因此,網站就可以釋出 TrueType 字型資源,並只允許已授權網站進行跨站呼叫。- WebGL 貼圖
- 使用
drawImage
將Images/video
畫面繪製到canvas
。 - 來自影像的 CSS 圖形 (en-US)
Request Type
請求型別有Fetch/XHR
、JS
、CSS
、Img
、Media
、Font
、Doc
、WS (WebSocket)
、Wasm (WebAssembly)
、Manifest
或 other
(此處未列出的任何其他型別),從chrome
網路皮膚可以篩選檢視。
根據 cross-origin sharing standard ,可知數以Doc
型別的位址列請求、form
表單請求不會受同源策略限制,<script src="url"></script>
與<link href=""></link>
也不會受同源策略限制,但JavaScript
指令碼內部發起的fetch/ajax
請求會受到同源策略的限制。
如果在位址列直接請求js
、css
、png
資源,請求型別也是document
,同樣不受同源策略影響。
用fetch/ajax
請求這類資源也不會跨域,因為CDN
服務一般會設定Access-Control-Allow-Origin: *
。
瀏覽器位址列裡面輸入一個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
規定,不存在跨域問題。
為什麼服務端指定了響應標頭允許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
預檢請求。
(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
預檢請求(與第一個場景的不同點)。
注:以上場景均在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.
對於簡單請求,瀏覽器會跳過 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.
即使瀏覽器給簡單請求設定了非 簡單頭欄位(如 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
OSX
下 Chrome
的行為是合理的,即使設定了 DNT
也會直接跟隨重定向。
後端服務攔截處理
後端服務透過判斷請求標頭Origin
是否在允許的白名單中,如果在,則設定Access-Control-Allow-Origin
的值為請求標頭Origin
場景復現與分析
場景一復現:
本地搭建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', {});
日誌列印:
結論:可以看到非簡單請求後的重定向請求確實會傳送preflight
預檢請求,當從http://127.0.0.1:3001/order/id
重定向到http://127.0.0.1:3002/order/user
,請求標頭Origin
為null
字串,不在白名單中,響應不會攜帶Access-Control-Allow-Origin
,自然就跨域了。
注意:預檢請求返回狀態碼為200並不意味著其透過了跨域檢查,是否透過跨域檢查主要看請求標頭Origin
與Access-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();
}
日誌列印
結論:可以看到簡單請求後的重定向請求不會傳送預檢請求,當從http://127.0.0.1:3001/order/id
重定向到http://127.0.0.1:3002/order/user
,請求標頭Origin
同樣為null
字串,跨域了。
解決方案
(1)將null
字串加入白名單,前端攔截重定向到登入頁
當跨域名重定向時,請求標頭Origin
為null
字串,可以將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
請求接收到該型別內容並不會渲染到頁籤中(以下為模擬場景)。
為了避免跨域並且又能跳轉到登陸頁,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>
但是採用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 = 0
和responseURL = ""
。
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 #
後面的內容會被刪除
- 場景二,介面返回
Http 303
響應,但沒有設定Location
標頭
- 場景三,介面返回
Http 302
響應,有設定Location
標頭,並且訪問重定向地址成功了
- 場景四,介面返回
Http 302
響應體,有設定Location
,但訪問重定向的地址跨域了
用原生XMLHttpRequest
請求,可以看到重定向跨域後reponseURL=""
axios
封裝xhr
的請求會包裝一個Network Error
錯誤
axios.get('http://127.0.0.1:3001/order/detail');
- 場景五,介面返回
Http 400
響應
axios
請求包裝錯誤資訊“Request failed with status code 400”
,與原生XMLHttpRequest
請求一樣responseURL
返回響應序列化的URL
- 場景六,介面返回
Http 500
響應
- 總結
除了跨域問題會導致status = 0
和responseURL = ""
,其他場景下responseURL
都會返回具體值。因此可以根據這些條件在前端攔截跨域告警,重定向到登入頁。