Contents

CORS 解決方案:前端如何處理收不到 Response Header 的問題

最近我設計了一個 OAuth Token 驗證給前端串接,但發現前端無法抓取到我後端的 Response Header。經過一番研究,我發現問題出在 CORS 上。以前我對 CORS 都是簡單了解,沒想到 CORS 規範的內容如此豐富。這篇文章就是我對此進行深入研究的記錄。這篇還一點債了…

心智圖

因為 mermaid 無法用-,所以用_置換。

mindmap Same_Origin Policy Origin Tuple origin schema+host+port opaque origin file://.../xxx.html CORS 簡單請求 發送Request不會預檢 Request會送到Server Response 都會檢查 Access_Control_Allow_Origin 非簡單請求 發送Request會預檢 預檢沒過,Request不會送到Server Response 都會檢查 Access_Control_Allow_Origin CORS 容易忽略那些事 Request 不能隨意帶 Header JS不能隨意用 Response Header CORS 相關 Header Access_Control_Allow_Origin Access_Control_Allow_Methods Access_Control_Allow_Headers Access_Control_Max_Age Access_Control_Expose_Headers Access_Control_Allow_Credentials

Origin Header

1
2
3
Origin: null
Origin: <scheme>://<hostname>
Origin: <scheme>://<hostname>:<port>

常見我們 AJAX 跨域會看到 HTTP 有 Origin Header 內容。

從 MDN 我們看到:

Origin 標頭與 Referer 標頭類似,但前者不會暴露 URL 的 path 部分,而且其可以為 null 值。其用於為源站的請求提供 “安全上下文”,除非源站的資訊敏感或不必要的。

在深入研究之前,我們需要先了解 Origin 的規則。對這些規則有基本的理解,將有助於我們更好地理解後續的內容。我之前在 程式狂想筆記 中有寫過一篇關於 SameSite Cookie 和 Same Origin Policy 的文章,您可以參考。io/blog/posts/SameSite-Cookie-%E5%92%8C-Same-Origin-Policy-%E6%98%AF%E4%BB%80%E9%BA%BC/)有寫這篇筆記有記錄到。

Warning

我們經常會看到 Same-Origin Policy,也就是所謂的 同源政策。雖然這個詞彙中包含了 Origin,但它與 CORS 的意義並不相同。MDN 上有一篇關於同源政策和 CORS 的文章,我們可以參考一下 same-origin_policy_and_cors

簡單來說,Same-Origin Policy 是 Web 的一種基本安全機制,它限制了一個源 (origin) 的文件或腳本與來自另一個源的資源的交互方式。這種政策有助於隔離潛在的惡意文件,從而減少可能的攻擊。如果您想了解更多,可以參考 MDN 上的 Same-origin policy

然而,在實際操作中,有些站點之間的交互是正常的,我們需要透過 CORS 來解除這種限制。

opaque origin

從這篇忍術!把 same site 變 same origin 之術! - Huli’s blog 看到裡面提到 opaque origin,在開啟 file://xxx.html 做跨域請求, Origin Header 會顯示 null

接著規範裡把 origin 分成兩種,一種是 An opaque origin,另一種是 A tuple origin。

Opaque origin 可以想成是在特殊狀況下才會出現的 origin,例如說我在本機開啟一個網頁,網址會是 file:///…,這時候在網頁內發送 request,origin 就會是 opaque origin,也就是 null。

Tuple origin 則是比較常見而且我們也比較關心的 origin

我這邊就做個簡單實驗,隨便新增一個 xxx.html 用瀏覽器打開,Console 打個 fetch('https://tw.yahoo.com')。這時候就可以看到你的 header 有這個東西。

https://user-images.githubusercontent.com/6058558/262520509-a126493e-03e4-4f59-8a92-3997ae3ddbc6.png

http - When do browsers send the Origin header? When do browsers set the origin to null? - Stack Overflow

要嘛兩個是一樣的 opaque origin,否則的話要 scheme、host 跟 port 三者都相等,才是 same origin。除了 same origin 以外,你還會在 spec 裡面看到另外一個詞叫做「same origin-domain」,這個我們之後也會提到。

我這邊有自己實驗 file:///C:/Users/steve/Desktop/test.htm 做 AJAXfile:///C:/Users/steve/Desktop/test2.htm,沒版法取得資料,就如上訴原因。

Warning
這邊注意 CORS 非同源(Origin)的話疑慮都會觸發請求,但同源在某種情況也會觸發請求,我們繼續往下看。

請求預檢

我們在使用 Ajax 進行跨域操作時,常常會看到 OPTIONS 請求。這實際上是一種 預檢 的動作(我們稍後會進一步解釋這個概念)。

https://user-images.githubusercontent.com/75846914/262224916-eb4763ca-d7a8-4dca-bc65-868f48fdaab7.png

過去,我們可能會誤以為只要網域不同就會進行跨域操作,但實際上規則更為細緻。**簡單請求不會進行預檢,而非簡單請求則會進行預檢。**接下來,我們將進一步了解什麼是簡單請求和非簡單請求。

1
2
3
4
5
curl "https://#url#"    \
-X OPTIONS     \
-H "Access-Control-Request-Method: GET"    \
-H "Access-Control-Request-Headers: content-type"   
-H "Origin: #url#"  -v 

CORS 種類

我們在進行跨域操作時,有時會看到 OPTIONS 請求,有時則不會。最近我讀到一篇文章,提到非簡單請求會進行 請求預檢 (preflight request)。我才知道有這種詳細機制。

CORS 簡單請求

一般為 Get,Post,Head 方法和 Content-Type 為傳統表單發送不會做預檢。但是還是會發送到 Server,而瀏覽器會判斷 Response Access-Control-Allow-Origin 有沒有這個 Header,沒有的話瀏覽器會阻擋程式成功這個請求,程式無法讀到 Response 內容,會報一個錯誤。

Info

這邊我說的傳統表單 Content-Typeapplication/x-www-form-urlencodedmultipart/form-data 或是 text/plain。這邊只列出一點規則,詳細可以看這邊

  • 只使用了下面的安全首部字段,不得人為設定其他首部字段
    Accept
    Accept-Language
    Content-Language
    Content-Type僅限以下透明度
    text/plain
    multipart/form-data
    application/x-www-form-urlencoded
  • HTML頭部欄位欄位:DPR、Download、Save-Data、Viewport-Width、WIdth
  • 請求中的各個XMLHttpRequestUpload物件均沒有註冊任何事件監聽器;XMLHttpRequestUpload 物件可以使用 XMLHttpRequest.upload 屬性存取
  • 請求中沒有使用 ReadableStream 對象
這邊我只寫簡單重點,詳細可以看 CORS 完全手冊(一):為什麼會發生 CORS 錯誤? - Huli’s blog,從裡面可以看到一般傳統請求不會做預檢動作,瀏覽器送出請求都是會送出去。Server 是會收到請求做處理,但是瀏覽器會阻擋。裡面提到兩個問題也很有趣。
Warning
儘管一般的 CORS 解決方法都會建議加入 Access-Control-Allow-Origin,但有時候,僅僅加入這個標頭並不能完全解決 CORS 問題。在這種情況下,這邊需要繼續看下去。
Info
講一下看到的冷知識,一般傳統 form 送出去,POST 送出跨域我們知道不會被瀏覽器阻擋。但其實他在 Header 也有加上 Origin,Form 表單送出 GET 方法就沒有這個東西。
可參考: CORS 完全手冊(四):一起看規範 - Huli’s blog

CORS 非簡單請求

當我們使用 AJAX 進行 POST 請求,並且 Content-Type 設為 Application/Json 時,這種請求會被視為非簡單請求。如果請求中包含了非常見的Header),則該請求會被瀏覽器阻擋。(這裡我們先不詳細討論 Header)

Info

一般 AJAX 做 POST 時候,Content-Type 都會帶 Application/Json

我在這裡並沒有詳細說明何時會被視為非簡單請求,因為這個主題相當廣泛,且初學者可能會覺得難以理解。實際上,只有當請求方法為 GETHEADPOST,且標頭符合 CORS-safelisted request-header 時,該請求才會被視為簡單請求。

詳細可以看: CORS 完全手冊(四):一起看規範 - Huli’s blog

https://user-images.githubusercontent.com/6058558/262536186-4c4c0795-9821-43e8-a9c8-22d4c242d295.png

在上述範例中,除了我們在簡單查詢中需要帶的標頭之外,我們還看到在進行 請求預檢 時,會多帶 Access-Control-Request-MethodAccess-Control-Request-Headers 兩個標頭。這兩個標頭在預檢請求的回應中,會對應到 Access-Control-Allow-MethodsAccess-Control-Allow-Headers。預檢請求完成後,瀏覽器會進行與簡單請求相同的步驟。

Info
不管是簡單請求還是非簡單請求,Request 一般都會帶有 Origin,而 Response 則需要加上 Access-Control-Allow-Origin 標頭。無論 Request 方法是 OPTIONS 還是 POST,這兩種請求都需要包含 Access-Control-Allow-Origin 標頭。

Request 不能隨意帶 Header

在上述情況中,我們的 Request 符合 CORS-safelisted request-header,因此不需要進行請求預檢。讓我們進行一個簡單的實驗來驗證這一點:首先,打開這個網頁 https://web.cyut.edu.tw/,然後在 Console 中輸入以下指令。

1
2
3
4
5
6
fetch('https://web.cyut.edu.tw/', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
  },
})

https://user-images.githubusercontent.com/6058558/262574405-a16c46e9-6f0f-490c-a92b-616548a085e6.png

然而,如果我們在請求中加入任意的標頭,就會觸發請求預檢

1
2
3
4
5
6
7
fetch('https://web.cyut.edu.tw/', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
    'test': 'test',
  },
})

https://user-images.githubusercontent.com/6058558/262575037-5c465ee4-f748-4822-9a69-a34d551e1eff.png

Info

上面簡單範例是自訂 header 會做請求預檢,但實際上不是自訂才會。是不符合 CORS-safelisted request-header 才會,API 常用帶 Token 在 Header 上,會觸發請求預檢。

1
2
3
4
5
6
7
fetch('https://web.cyut.edu.tw/', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
    'Authorization': 'Bearer test'
  },
})

https://user-images.githubusercontent.com/6058558/262575545-6750112b-246d-40af-839d-981e0a1f8457.png

有哪些符合可以看 CORS-safelisted request header - MDN Web Docs Glossary: Definitions of Web-related terms | MDN

如果你手動進行嘗試,你會發現 請求預檢 中多了 Access-Control-Request-Headers,其中包含了你的程式中帶的標頭。這些都是瀏覽器自動為你處理的。當後端回應 Access-Control-Allow-Headers 時,表示你可以發送 API 請求。此時,你會發現沒有任何錯誤,這表示問題已經被解決了。

然而,如果你設計了讓前端抓取 Response Header,當後端回應 Access-Control-Allow-Headers 時,你可能會發現前端無法抓取到 Response Header,即使你已經設定了相關的 CORS 設定,且程式並未出現錯誤。這是因為瀏覽器並未將相關 Header 傳給前端

Info
Access-Control-Request-Headers 可以放多個值,他是用, 分開的。但其實你不需要管怎麼放,畢竟是瀏覽器幫你做到的。
https://user-images.githubusercontent.com/6058558/262591123-cb343d6c-e8d1-4133-bc4f-fbb7d112c3bb.png

JS 不能隨意用 Response Header

上面章節有提到,AJAX 通了,假如你有設計 Response 給前端抓 Header,你會發現沒辦法抓到 Header 參數。解決方法就是在後端 API Response 加上 Access-Control-Expose-Headers Header,就可以解決這個問題。

Access-Control-Expose-Headers 通常會帶完整參數,很多框架實作上沒有加上 *,都是會回傳多個值 (header1,header2),但我測試用 *,Response 就能吃到 Header 值。

https://user-images.githubusercontent.com/6058558/262593389-28b35165-c1c1-4010-9661-f9fa04fa1e1b.png

1
2
3
4
5
6
7
await fetch("http://localhost:5206/WeatherForecast", {
    "headers": {
"test":"test",
"test2":"test2value"
    },
    "method": "GET",
}).then( res=> res.headers);

前端 Header 可以抓到值了。
https://user-images.githubusercontent.com/6058558/262594265-34655996-5874-4f66-b65f-675d60857e4e.png

Warning
實際上,本篇的問題就是由於缺少一個 Header,導致前端無法抓取到資料。然而,我建議在未來的開發中儘量避免在 Response Header 中添加資料。如果你需要使用它,請注意跨域的問題。

Access-Control-Allow-Credentials

這個設定是用於跨域分享 Cookies。在使用 fetch 時可能會用到。理論上,現在的 API 通常不會使用 Cookies,所以通常不會設定這個值,除非你的服務有 Web 頁面使用到 Session 或 Cookies。

Access-Control-Allow-Methods

這個設定允許前端呼叫的請求方法。這部分相對直觀,不再詳細說明。

Access-Control-Max-Age

CORS-preflight request 也是 CORS request 的一種,所以上面所說的針對 CORS request 可以給的 response 也都可以給。

而除此之外還定義了另外三個:

1
2
3
Access-Control-Allow-Methods:可以使用哪些 method
Access-Control-Allow-Headers:可以使用哪些 header
Access-Control-Max-Age:前兩個 header 可以快取多久

這邊值得注意的是第三個,預設值是 5 秒,所以 5 秒內針對同一個資源的 CORS response header 是可以重用的。

引用: CORS 完全手冊(四):一起看規範 - Huli’s blog

你真的會懂 CORS?

我們通常在網路上學習如何進行跨域設定,你可能會這樣設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
builder.Services.AddCors(options =>
{
    options.AddPolicy("CorsPolicy",
        builder =>
        {
            builder.WithOrigins("*")
            .AllowAnyHeader()
            .AllowAnyMethod();
        });
});

在寫這篇文章時,我對後端程式框架設定 CORS 的理解並不深入,但讀了以下的資料後,我對 CORS 有了更深的理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
builder.Services.AddCors(options =>
{
    options.AddPolicy("CorsPolicy",
        builder =>
        {
            builder.WithOrigins("*") // --> Access-Control-Allow-Origin: *
            // .WithHeaders("Device")   // --> Access-Control-Allow-Headers: Device
            // .WithHeaders("HeaderValue") // --> Access-Control-Allow-Headers: HeaderValue
            .AllowAnyHeader()  // --> 會組合前端帶的 Header (Access-Control-Allow-Headers: Device,HeaderValue)
            .AllowAnyMethod()    //  --> Access-Control-Allow-Methods: PUT, POST, GET, DELETE, OPTIONS  ==>理論上後端框架都會綁你算好,不會傳多餘的給前端
            //.AllowCredentials()   // Access-Control-Allow-Credentials: true
            // .SetPreflightMaxAge(TimeSpan.FromSeconds(10))  --> Access-Control-Max-Age: 10 (預設3秒)
            // WithHeadesr,WithOrigins 使用上都非常相似
            .WithExposedHeaders("Device","HeaderValue");  // --> Access-Control-Expose-Headers: Device,HeaderValue
        });
});

我相信還有很多內容可以學習,以下是我推薦的閱讀資料:

相關文章

彩蛋