掌握 CORS 跨域請求,讀這一篇文章就夠了

范家鵬發表於2022-12-14

在 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。如下圖所示:

Search Cors

3、同源與不同源的定義及舉例說明

同源策略是由 Netscape 提出的一個著名的安全策略,它是一種安全約定。目前,所有可支援 JS 的瀏覽器都會遵循這個策略。Ajax 是當代 Web 應用程式中獲取伺服器資料的核心技術,可以實現網頁內容非同步更新,Ajax 底層之 XMLHttpRequest 物件和 Fetch API 都遵循同源策略。同源策略也是瀏覽器基本的安全功能之一。

同源的定義:當兩個 URL 使用的協議、域名(主機)和埠都相同的情況下,則稱為兩個 URL 同源,反之稱兩個 URL 不同源。下表整理了同源與不同源的 URL 示例說明:

URL AURL 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.comhttp://www.example.com:80同源80 是 HTTP 協議預設埠
https://www.example.comhttps://www.example.com:443同源443 是 HTTPS 協議預設埠
https://www.example.comhttp://www.example.com不同源域名相同,協議不同
https://www.example.comhttps://www.example.com:81不同源域名相同,埠不同
https://www.example.comhttps://tool.example.com不同源主域名相同,二級域名不同
https://www.example.comhttps://example.com不同源主域名相同,子域名不同
https://www.example.comhttps://39.105.183.157不同源域名與 IP 不同
https://www.example.comhttps://tool.box3.cn不同源完全不同的域名
http://www.example.comhttp://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 皮膚中瞭解具體的報錯資訊。如下圖所示:

瀏覽器丟擲 CORS 錯誤詳情

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 程式碼。請注意,該值嚴格區分大小寫,正確的寫法是全小寫。

相關文章