跳至主要内容

[HTTP Security] CORS 跨來源資源共用

TL;DR;​

  • 預設的情況下, 瀏覽器同源會遵守同源政策 (same-origin policy), 並不允許跨域請求 (cross-origin). 在 server 未允許的情況下, 會得到 blocked by CORS policy 的錯誤.
  • 如果 client 希望能過取得跨域的資源, 需要由 server 在 response header 中帶上 Access-Control-Allow-Origin 的欄位.

Preface​

對接 API 時, 若沒有特別設定, 前後端在各自環境下測試 API 對接, 可能會出現這樣錯誤:

fetch('https://www.google.com')
.then(function (response) {
// Do something ...
})
.catch(function (error) {
// Do something else ...
});

CORS Error

若放到正式環境上, 可能又沒有這問題了. 這是什麼狀況呢? 仔細看錯誤訊息如下:

access to XMLHttpRequest at 連結A from origin 連結B 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

這錯誤訊息就是在說 傳送過去的 XMLhttpRequest 違反 CORS 規範 而無法存取. 需檢查 Web Server 上 Access-Control-Allow-Origin header 相關設定.

同源政策 (same-origin policy)​

在開始 CORS 之前, 要先知道瀏覽器的 Same Origin Policy (同源政策).

基於安全性考量, 程式碼所發出的跨來源 HTTP 請求會受到限制. 例如,XMLHttpRequest 及 Fetch 都遵守同源政策 (same-origin policy). 這代表網路應用程式所使用的 API 除非有回傳 CORS 標頭 (例如: Access-Control-Allow-Origin). 否則只能請求與應用程式相同來源的 HTTP 資源.

在這個情況下, 其實請求 (request) 已經發出去了, 而瀏覽器其實也拿到回應 (response), 但是瀏覽器基於同源政策, 因此不把拿到的回應給你的 JavaScript 去做進一步的處理.

SOP

同源/跨來源 怎麼判定​

當兩個網址的 schema(protocol) + host + port 皆相同, 就是同源 (Same-Origin). 只要有一者不同, 就是跨來源 (Cross-origin)

一般來說跨來源寫 (Cross-origin writes), 跨來源嵌入 (Cross-origin embedding) 是被允許的, 而跨來源讀取 (Cross-origin reads) 是受限制的. 而實際上, 跨來源請求資源是相當常見的情況, CORS 就是為了解決跨來源共用資源的規範.

CORS​

跨來源資源共用(Cross-Origin Resource Sharing (CORS))是一種使用額外 HTTP 標頭令目前瀏覽網站的使用者代理取得存取其他來源(網域)伺服器特定資源權限的機制。 當使用者代理請求一個不是目前文件來源——例如來自於不同網域(domain)、通訊協定(protocol) 或通訊埠(port)的資源時,會建立一個跨來源 HTTP 請求(cross-origin HTTP request)。

MDN

開啟跨來源請求​

若要開啟跨來源請求, 必須在伺服器端做一些設定, 像是在 Response Header 加上 Access-Control-Allow-Origin

Access-Control-Allow-Origin: * # 允許所有網站發送的請求
Access-Control-Allow-Origin: http://foo.example # 只允許 http://foo.example 的請求

這裡 * 就表示 接受所有不同來源 的跨域請求. 當瀏覽器接受到伺服器的回應時, 會去比對這個內容, 如果目前的 Origin 符合 Access-Control-Allow-Origin 所定義的規則的話, 瀏覽器才會把回應給你.

另外還有其他和跨域請求時可用的 Header 像是:

Access-Control-Allow-Headers:
Access-Control-Allow-Methods: ["GET", "POST", "PUT"]
Access-Control-Expose-Headers: X-My-Custom-Header, X-Another-Custom-Header
Access-Control-Max-Age: <delta-seconds>
Access-Control-Allow-Credentials: true
  • Access-Control-Allow-Headers 指定哪些 HTTP 標頭可以於實際請求中使用。
  • Access-Control-Allow-Methods 存取資源所允許的方法,用來回應預檢請求。
  • Access-Control-Expose-Headers 瀏覽器能夠存取伺服器回應當中哪些標頭。
  • Access-Control-Max-Age 預檢請求之結果可以被快取的秒數。
  • Access-Control-Allow-Credentials 用於驗證請求中。

簡單請求與預檢請求​

跨來源請求又可以分成 簡單請求 (simple request) 和 預檢請求 (preflight request).

簡單請求 (Simple Request)​

簡單請求有一些規範, 基本上使用的一定要是 GET, HEAD, POST 方法, 並且 不能有客制的 header, 僅允許特定的標頭和內容, 如此才算是簡單請求.

備註 雖然稱作是簡單請求, 但它依然會遵循 同源政策 (Same Origin Policy) 的規範.

預檢請求 (Preflight Request)​

預檢請求通常是在發送會帶有副作用的 HTTP 請求方法前, 規範瀏覽器要先發送預檢請求, 預檢請求會以 HTTP OPTIONS 的方法送出, 以向伺服器確認後續的請求能否傳送, 如果預檢請求沒有通過, 那麼後續真正要發送的實際請求 (例如: POST, PUT, DELETE 等等) 就不會發送了.

預檢請求和簡單請求有一點不同, 如果是簡單請求而被 CORS 攔下來的話, 實際上請求已經發送出去, 只是被瀏覽器擋下來不給你. 但若是預檢請求, 發送預檢請求後若沒有通過, 真正要發送的請求是不會發送出去的. 也就是說 一旦預檢請求完成, 真正的請求才會被送出.

預檢請求流程

用戶端向伺服器端發送預檢請求 (瀏覽器會自動發送)​

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

伺服器回應預檢請求​

在這些資訊下, 接著伺服器將會確定是否接受請求, 並傳送以下回覆:

HTTP/1.1 200 OK
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
  • Access-Control-Allow-Methods 標頭表示伺服器可以接受 POST, GET, OPTIONS 方法.
  • Access-Control-Allow-Headers 標頭及其值 X-PINGOTHER, Content-Type 表示伺服器允許在實際請求中使用以上這兩個標頭.
  • Access-Control-Max-Age 提供了本次預檢請求回應所可以快取的秒數, 如此將可避免瀏覽器不斷發送 preflight request. 在此範例中 86400 秒即為 24 小時. 每一個瀏覽器都有預設的最大值, 當 Access-Control-Max-Age 較預設值大時會優先採用預設值.

Cookie, Authorization Header 和 TLS 的憑證, 預設也只會自動帶入到同源所發出的請求中.

有時我們的 Cookie 會需要發送到不同來源 (origin) 的位置, 例如 server 的 URL 是 https://api.example.com , 但 client 的 URL 是 https://dev.example.com . 因為 subdomain 不同的緣故, 會被認定是不同的 origin. 因此 client 對 server 所發送的請求並不會自動帶上 Cookie.

要解決這個問題, 如果 client 使用的是 fetch API, 則需要加上 credentials: include; 的 options. 否則 fetch 預設不會傳送 cross-origin cookies.

// client
fetch('https://api.example.com', {
credentials: 'include',
});

而 server 則是要回傳 Access-Control-Allow-Credentials 的 header 在 response 中:

headers.Set("Access-Control-Allow-Credentials", "true")

See Also​

遇到 CORS 的問題, 可以歸納出這樣的 SOP:

  1. 如果是「簡單」的跨來源請求, 常見開發環境下整合測試需求. 後端 GET/POST/HEAD 方法本身加上 Access-Control-Allow-Origin header.
  2. 非「簡單」跨來源請求, 在後端 OPTIONS 加上 Access-Control-Allow-Methods 及 Access-Control-Allow-Headers header. 另外, 在後端方法本身加上 Access-Control-Allow-Origin header.
  3. 需要使用 cookie 的情況下, 前端要加上 credentials: 'include' 或是 withCredentials 參數, 後端要加上 Access-Control-Allow-Credentials header, 而且 Access-Control-Allow-Origin header 不能用 *.

簡單整理 CORS 相關資料, 進階了解推薦閱讀 CORS 完全手冊. 而常見的後端語言中, 除了依需求手刻加上 header 外, 也有專門處理 CORS 相關套件可加速開發與方便部署配置.

References​