在 Web 前後端分離架構模式下,跨域(跨源)請求屬於日常的基本情況了。瀏覽器出於安全考慮,會限制 JavaScript(簡稱 JS)指令碼內發起跨源 HTTP 請求,同源沒有此類限制。前端解決跨域方法有很多,比如 WebSocket 協議跨域、JSONP 請求跨域和跨域資源共享 CORS 等。
1、CORS 簡介
CORS 全稱為 Cross-Origin Resource Sharing,被譯為跨域資源共享,簡稱跨域訪問,是 W3C 制定的標準協議。它由一系列傳輸的 HTTP 標頭(首部欄位)組成,瀏覽器會根據這些 HTTP 標頭決定著是否阻止前端 JS 程式碼獲取跨域請求的資源。CORS 主要作用是消除各種 API 的同源限制,以便在不同源(伺服器)之間共享資源,且確保跨域資料傳輸的安全性。
CORS 請求並不是一種特殊的 HTTP 請求,同樣基於 HTTP 通訊協議。CORS 請求預設攜帶"origin"標頭,用於向目標網站指明請求的來源。origin 欄位由三部分組成:協議、主機和埠,以下三種語法都是正確的。
origin: null
origin: <scheme>://<hostname>
origin: <scheme>://<hostname>:<port>
2、查詢瀏覽器的相容性
推薦一個查詢瀏覽器特性、相容性以及相容到具體哪個版本的網站。例如查詢各瀏覽器對 CORS 的支援情況,訪問 URL 地址 https://caniuse.com/?search=CORS。如下圖所示:
3、同源與不同源的定義及舉例說明
同源策略是由 Netscape 提出的一個著名的安全策略,它是一種安全約定。目前,所有可支援 JS 的瀏覽器都會遵循這個策略。Ajax 是當代 Web 應用程式中獲取伺服器資料的核心技術,可以實現網頁內容非同步更新,Ajax 底層之 XMLHttpRequest 物件和 Fetch API 都遵循同源策略。同源策略也是瀏覽器基本的安全功能之一。
同源的定義:當兩個 URL 使用的協議、域名(主機)和埠都相同的情況下,則稱為兩個 URL 同源,反之稱兩個 URL 不同源。下表整理了同源與不同源的 URL 示例說明:
URL A | URL B | 結果 | 分析原因 |
---|---|---|---|
https://www.example.com/a/ | https://www.example.com/b/ | 同源 | 域名相同,只有路徑不同 |
https://www.example.com/a/ | https://www.example.com/a/c/ | 同源 | 域名相同,只有路徑不同 |
http://www.example.com | http://www.example.com:80 | 同源 | 80 是 HTTP 協議預設埠 |
https://www.example.com | https://www.example.com:443 | 同源 | 443 是 HTTPS 協議預設埠 |
https://www.example.com | http://www.example.com | 不同源 | 域名相同,協議不同 |
https://www.example.com | https://www.example.com:81 | 不同源 | 域名相同,埠不同 |
https://www.example.com | https://tool.example.com | 不同源 | 主域名相同,二級域名不同 |
https://www.example.com | https://example.com | 不同源 | 主域名相同,子域名不同 |
https://www.example.com | https://39.105.183.157 | 不同源 | 域名與 IP 不同 |
https://www.example.com | https://tool.box3.cn | 不同源 | 完全不同的域名 |
http://www.example.com | http://localhost | 不同源 | 完全不同的域名 |
4、常見的 CORS 訪問控制場景
本例中,Nginx 伺服器開啟了 HTTP/2 協議,因此在 HTTP/2 二進位制編碼之前,必須將 HTTP 標頭名稱轉換為小寫。若請求頭、響應頭中包含大寫的欄位名將被視為格式錯誤。
關鍵知識點:如果 CORS 跨域請求是這三種方法之一:GET、POST 或 HEAD,那麼在 HTTP 響應頭中並不需要指明 access-control-allow-methods 欄位的值。
4.1 簡單請求
什麼是簡單請求?如果滿足下述所有條件,才會被認定為"簡單請求"。請注意,對於"簡單請求"瀏覽器不會發起 CORS 預檢請求。
1、HTTP 請求方法是以下三種之一:
- GET
- POST
- HEAD
2、除了瀏覽器自動新增的首部欄位(例如:connection,user-agent、date、referer 等)和 fetch 規範中定義的禁止使用的首部欄位,以及"proxy-"和"sec-"小寫開頭的首部欄位。允許設定的首部欄位集合為:
- accept
- accept-language
- content-language
- content-type(見下列 3 )
3、content-type 的值是下列三者之一:
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
4、請求中的任意 XMLHttpRequest 物件均沒有註冊任何事件監聽器;XMLHttpRequest 物件可以使用 XMLHttpRequest.upload 屬性訪問。
5、請求中沒有使用 ReadableStream 物件。
例如,請看一個 CORS 簡單請求的例子,使用者訪問站點 https://tool.box3.cn,頁面嘗試跨域請求從 https://api.box3.cn 獲取資料,發起跨域請求的 JS 程式碼如下所示:
const xhr = new XMLHttpRequest();
const url = 'https://api.box3.cn/example/simple';
xhr.open('GET', url);
xhr.send();
以下是瀏覽器傳送給伺服器的請求報文(關鍵部分資訊):
:method: GET
:authority: api.box3.cn
:scheme: https
:path: /example/simple
origin: https://tool.box3.cn
user-agent: Mozilla/5.0 ... ...
以下是伺服器返回的響應報文(關鍵部分資訊):
:status: 200 OK
server: nginx
date: Thu, 17 Nov 2022 02:35:49 GMT
content-type: application/json; charset=utf-8
content-length: 47
access-control-allow-origin: *
本例中,伺服器返回的首部欄位 access-control-allow-origin: * 表明,該資源可以被任意外部域訪問或接受所有的請求源。
access-control-allow-origin: *
如果只希望伺服器允許來自 https://www.example.com 的訪問,該首部欄位的內容如下:
access-control-allow-origin: https://www.example.com
關鍵知識點:當響應的是附帶身份憑證的請求時(例如:Cookie),伺服器必須明確 access-control-allow-origin 欄位的值,而不能使用萬用字元"*",否則瀏覽器的同源策略會阻止該請求,並在控制檯丟擲錯誤。
4.2 預檢請求和實際請求
首先,當請求發生跨域行為,且非簡單請求時,才會產生 CORS 預檢請求(CORS-preflight request)。其次與"簡單請求"不同的是,"預檢請求"是由瀏覽器自動發起的一個額外的 OPTIONS 請求,以獲知伺服器是否授權後續的實際請求(例如:XHR 或 Fetch API 發起的 HTTP 跨域請求)。其次,OPTIONS 請求包含了兩個重要的標頭(首部欄位)access-control-request-method 和 access-control-request-headers。
如下是一段需要發起 HTTP 預檢請求的 JS 程式碼示例:
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.box3.cn/example/request');
xhr.setRequestHeader('box3-token', '111-222-333-444');
xhr.send();
如上程式碼使用 GET 請求從伺服器獲取資料,該請求包含了一個自定義的請求頭(box3-token:111-222-333-444)。因為該欄位名超出了"簡單請求"的定義範圍,所以瀏覽器自行判斷出這是一個非簡單請求,在"實際請求"發起之前,會先發起一個"預檢請求"。
下面是瀏覽器與伺服器首次互動的報文資訊,包括預檢請求頭和預檢響應頭(備註:user-agent 省略了部分內容):
/* 預檢請求頭 */
:method: OPTIONS
:authority: api.box3.cn
:scheme: https
:path: /example/request
access-control-request-method: GET
access-control-request-headers: box3-token
origin: https://tool.box3.cn
user-agent: Mozilla/5.0 ... ...
/* 預檢響應頭 */
:status: 204 No Content
server: nginx
date: Thu, 17 Nov 2022 02:35:35 GMT
access-control-allow-headers: box3-token
access-control-allow-origin: *
access-control-request-headers 告知伺服器實際請求攜帶的自定義標頭,access-control-allow-headers 告知客戶端已支援的所有自定義標頭,多個值之間以逗號分隔。
一般而言,伺服器會對 OPTIONS 請求的結果新增快取時間。目的是,客戶端減少了預檢請求互動的時間,同時也減少了對伺服器的壓力。比如伺服器在響應頭中指定 access-control-max-age: 3600 表示該響應的有效時間為 3600 秒,也就是 1 小時。在這段時間內,瀏覽器不會對同一請求再次發起預檢請求,而是直接發起實際情況。
新增預檢請求快取之後,本例的預檢響應頭,最新內容如下:
:status: 204 No Content
server: nginx
date: Thu, 17 Nov 2022 02:35:35 GMT
access-control-allow-headers: box3-token
access-control-allow-origin: *
access-control-max-age: 3600
關鍵知識點:對於 OPTIONS 請求,合法的 HTTP 狀態碼,應該定義在 2xx 範圍內。比如狀態碼設定為 200 或 204,都是正確的。
最後,待預檢請求透過之後,瀏覽器再傳送實際請求。下面是實際請求的請求頭和響應頭:
/* 實際請求的請求頭 */
:method: GET
:authority: api.box3.cn
:scheme: https
:path: /example/request
box3-token: 111-222-333-444
origin: https://tool.box3.cn
user-agent: Mozilla/5.0 ... ...
/* 實際請求的響應頭 */
:status: 200 OK
server: nginx
date: Thu, 17 Nov 2022 02:35:35 GMT
content-type: application/json; charset=utf-8
content-length: 45
access-control-allow-origin: *
4.3 簡單請求和憑據
預設情況下,對於 XMLHttpRequest 或 Fetch API 發起的跨域請求,瀏覽器不會傳送 Cookie 資訊。若要攜帶 Cookie,以 XMLHttpRequest 物件為例,需要設定屬性 withCredentials 的值為 true。
本例中,站點 https://tool.box3.cn 內的 JS 指令碼向 https://api.box3.cn 發起了一個簡單的 GET 跨域請求,並附帶了身份憑證 Cookie。JS 示例程式碼如下:
const xhr = new XMLHttpRequest();
const url = 'https://api.box3.cn/example/simple_cookie';
xhr.open('GET', url);
xhr.withCredentials = true;
xhr.send();
下面是瀏覽器與伺服器互動的報文資訊之關鍵部分(備註:user-agent 省略了部分內容):
/* 簡單請求的請求頭 */
:method: GET
:authority: api.box3.cn
:path: /example/simple_cookie
:scheme: https
cookie: access-token=100;
origin: https://tool.box3.cn
user-agent: Mozilla/5.0 ... ...
/* 簡單請求的響應頭 */
:status: 200 OK
server: nginx
date: Thu, 17 Nov 2022 02:52:07 GMT
content-type: application/json; charset=utf-8
content-length: 45
access-control-allow-credentials: true
access-control-allow-origin: https://tool.box3.cn
關鍵知識點:
1、伺服器在響應頭中必須指定 access-control-allow-credentials: true 來表明跨域請求允許攜帶 Cookie,否則仍然會被瀏覽器的 CORS 策略阻止。
2、伺服器在響應頭中必須指定 access-control-allow-origin 欄位特定的域,該標頭的值不能設定為萬用字元 "*",否則仍然會被瀏覽器的 CORS 策略阻止。
4.4 預檢請求和憑據
首先,一個完整的 CORS 預檢請求,是由瀏覽器自動完成的,這個動作對使用者是無感知的。
其次,與"簡單請求和憑據"這小節整理的 CORS 策略知識點是一致的。那意味著,在 OPTIONS 請求的響應頭中必須明確指定 access-control-allow-credentials: true 和 access-control-allow-origin 欄位特定的域,否則後續的實際請求仍然會被瀏覽器的 CORS 策略阻止。
最後,在實際請求的響應頭中,也需要明確指定這兩個欄位且保持與 OPTIONS 相同的值。
關鍵知識點:如果實際請求的 HTTP 方法,非 GET、POST 或 HEAD,那麼 access-control-allow-methods 欄位的值不能設定為萬用字元"*",應設定為特定的 HTTP 請求方法名稱,多個值之間以逗號分隔。
4.5 預檢請求與重定向
回顧 4.2 小節的關鍵知識點,預檢請求指的是 OPTIONS 請求,且 HTTP 狀態碼定義在 2xx 範圍內。因此,如果一個預檢請求發生了重定向,那麼 HTTP 狀態碼一定大於 2xx,大多數瀏覽器將報告如下錯誤:
Access to XMLHttpRequest at 'https://api.box3.cn/example/request_redirect' from origin 'https://tool.box3.cn' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.
有兩種方式可以規避上述報錯行為:
1、在服務端上去掉對預檢請求的重定向。
2、將該請求最佳化成一個簡單請求。
5、常見的 4 種 CORS 錯誤
常見的 CORS 跨域請求錯誤,可能有以下 4 種情況(以下首部欄位在伺服器上配置):
1、受信來源 access-control-allow-origin 配置不正確。
2、受信的 HTTP 方法 access-control-allow-methods 配置不全。
3、受信的首部欄位 access-control-allow-headers 配置不全。
4、access-control-allow-credentials 伺服器與請求方之間的憑證許可配置錯誤。
6、藉助瀏覽器找錯誤
引發 CORS 錯誤的原因是跨域請求失敗導致,並非 JS 程式碼層面出現的邏輯性 BUG。如果 JS 發起的 HTTP 請求產生 CORS 錯誤,在 JS 程式碼層面無法獲知具體是哪裡出了問題,但是您可透過瀏覽器控制檯獲悉錯誤資訊。例如在 Chrome 瀏覽器中,透過 F12 鍵啟動開發者除錯工具,在 Network 皮膚中瞭解具體的報錯資訊。如下圖所示:
7、認識這些 HTTP 請求頭和響應頭
7.1 HTTP 請求頭欄位
Header | 說明 |
---|---|
origin | 表明預檢請求或實際請求的源站。origin 的值只包括協議、域名、埠,不包含路徑和引數。 |
access-control-request-method | 出現於預檢請求中,其作用是,通知伺服器在實際請求中採用哪種 HTTP 方法。 |
access-control-request-headers | 出現於預檢請求中,其作用是,通知伺服器在實際請求中使用哪些 HTTP 請求頭。 |
7.2 HTTP 響應頭欄位
Header | 說明 |
---|---|
access-control-allow-origin | 指定請求的資源能共享給哪些域。該欄位只能指定一個來源。對於不需要攜帶身份憑證的請求,可以設定為萬用字元 *,表示允許所有來源訪問。 |
access-control-expose-headers | 在跨源訪問時,XMLHttpRequest 物件的 getResponseHeader() 方法只能拿到一些最基本的響應頭。如果需要獲取其他響應頭,透過該欄位新增白名單。 |
access-control-allow-methods | 對於預檢請求的響應,指明實際請求允許使用哪些 HTTP 方法。 |
access-control-allow-headers | 對於預檢請求的響應,指明實際請求允許攜帶哪些 HTTP 頭。 |
access-control-max-age | 指定預檢請求的有效期,單位是秒。目的是減少發起預檢請求的次數。 |
access-control-allow-credentials | 當設定為 true 時,告訴瀏覽器將響應公開給前端 JavaScript 程式碼。請注意,該值嚴格區分大小寫,正確的寫法是全小寫。 |