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