主題: Web platform

CORS 從頭學:請求送出、Cookie 與回應可讀,分開看才不會亂改

用白話流程與 fetch 範例,拆解 origin、OPTIONS 預檢、credentials、Cookie、Allow/Expose 標頭及 DevTools 除錯。理解 API 已執行卻讀不到回應的原因。

動態迷因(展開/收合)
API 紀錄說成功,網頁卻報錯?先分清楚操作完成與回應可讀,別急著重送。 · 來源:GIPHY

「Postman 明明可以,為什麼網頁不行?」看到 CORS 錯誤時,最容易開始亂改的地方,就是把前端 fetch、API 回應標頭與 Cookie 屬性當成同一組設定。

它們管的是不同階段。本文用一個網頁 https://app.example.test 呼叫 API https://api.example.test 的案例,逐步把參數放回正確位置。網址、token 和資料都是示意,不是可直接呼叫的服務。

我會先確認「哪一段失敗」,再改設定。因為網頁讀不到回應,不代表 API 沒收到請求,更不代表寫入已回滾。

1. 瀏覽器在保護誰?

如果你登入某個網站,另一個不可信網頁不應隨意讀出你的私人 API 資料。同源政策限制網頁跨來源讀取;CORS 則讓 API 用回應標頭,明確允許特定網頁來源讀取結果。

Postman、curl 與後端程式沒有同樣的網頁讀取限制。它們成功,能證明 API 在那次呼叫中有回應,不能證明瀏覽器已獲許可。MDN:同源政策

把一次呼叫拆成幾個問題,會比較好判讀:

  1. 瀏覽器有沒有送出實際請求?
  2. API 有沒有通過身分驗證與資料權限檢查?
  3. API 有沒有執行操作?
  4. 瀏覽器有沒有把回應交給 JavaScript?

最後一項失敗,前面仍可能成功。尤其是新增訂單、送出表單這類寫入,前端報錯後不能直接假設可以安全重試。

2. origin 是來源,不是完整網址

對一般 HTTP(S) 網址,origin 比較的是 scheme、hostname 與有效 port。路徑、query 與 hash 不算。

與 https://app.example.test 比較 同源? 理由
https://app.example.test/settings?tab=2 是 路徑和 query 不影響來源
https://app.example.test:443/ 是 443 是 HTTPS 的預設 port
https://app.example.test:8443/ 否 port 不同
http://app.example.test/ 否 scheme 不同
https://api.example.test/ 否 hostname 不同

所以兩個 localhost 服務用了不同 port,也不同源。程式裡可以這樣確認:

new URL("https://app.example.test:443/settings?tab=2").origin;
// "https://app.example.test"

Access-Control-Allow-Origin 要填這種來源,沒有路徑或最後的斜線。MDN:URL.origin

3. 哪些設定在前端,哪些在 API?

位置 例子 工作
前端 fetch 選項 credentials: "include" 允許使用合格的跨來源 credentials
前端 request header Authorization: Bearer demo-token 把明確的認證資訊送給 API
API response header Access-Control-Allow-Origin 允許這次網頁來源讀回應
Cookie 屬性 HttpOnly、SameSite、Secure 限制 Cookie 的存取與傳送

把 Allow-Origin 寫進 request,不會取得伺服器許可。一般網頁也不能任意偽造瀏覽器管理的 Origin 或 Cookie。本文的 request 示意會列出瀏覽器產生的標頭,但不表示它們都能由 JavaScript 自行設定。

4. 一般 GET 怎麼走?

讀取公開商品清單,可以先看這個前端範例:

const response = await fetch("https://api.example.test/products", {
  credentials: "omit",
});

if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const products = await response.json();

它沒有額外標頭,GET 也沒有 body。一般 CORS 流程是:

瀏覽器送 GET → API 處理並回應 → 瀏覽器檢查 CORS → JavaScript 取得可讀結果

API 可以限制給指定來源:

Access-Control-Allow-Origin: https://app.example.test
Content-Type: application/json

公開、不使用 credentials 的資料也可以回 Access-Control-Allow-Origin: *。每次回應只能使用一個來源或適用的星號,不能以逗號列出兩個來源。支援多個網站時,要先檢查可信清單,再回當次匹配的來源;不能無條件反射任意 Origin。MDN:Allow-Origin

這種不用預檢的請求常被稱為 simple request。名稱不代表操作安全,也不代表省掉了回應讀取檢查。MDN:CORS

5. OPTIONS 預檢看的是送出的請求

瀏覽器會先用 OPTIONS 詢問部分跨來源請求是否被允許。常見觸發條件是 PUT/PATCH/DELETE、程式設定 Authorization 或自訂標頭,以及 request 的 Content-Type: application/json。

一般免預檢必須符合全部相關條件,包含 GET/HEAD/POST、safelisted 標頭及其值的限制。請求 Content-Type 的常見合格 MIME type 是 application/x-www-form-urlencoded、multipart/form-data、text/plain。不只是看標頭名字。MDN:safelisted request headers

JSON 最容易搞混,先把方向寫出來:

設定 意思
Request Content-Type: application/json 我送的 body 是 JSON
Request Accept: application/json 我偏好收到 JSON
Response Content-Type: application/json API 回來的 body 是 JSON

API 回 JSON,本身不會使請求需要預檢。前端用 POST 送 JSON,才是上面提到的請求條件。

6. GET 加 Authorization,沒有 body 也會預檢

const response = await fetch("https://api.example.test/profile", {
  headers: { Authorization: "Bearer demo-token" },
  credentials: "omit",
});

if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const profile = await response.json();

Authorization 不在一般 safelist。沒有可重用的預檢許可時,瀏覽器先送:

OPTIONS /profile HTTP/1.1
Origin: https://app.example.test
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization

最後一行只列標頭名稱,不包含 Bearer token 的值。API 的預檢回應可像這樣:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Headers: Authorization

通過後,瀏覽器才送帶 token 的實際 GET。API 要驗證 token 值與資料權限,並在實際回應提供 CORS 標頭。

Allow-Headers 允許的是標頭名稱,不會讓 token 自動有效。Authorization 也是 wildcard 特例,必須明確列出,不能只靠 Allow-Headers: *。上面的 omit 不會刪除程式自己設定的 Authorization;它與瀏覽器自動攜帶 Cookie 的處理不同。MDN:Allow-Headers、Fetch Standard

這裡要分清楚規格與實作:瀏覽器對這個 wildcard 特例的支援並不一致。某個版本接受星號,不代表這份設定符合規格,也不代表換個瀏覽器還能運作。實際設定仍應明列 Authorization,而不是依賴寬鬆的實作行為。MDN 相容性資料

7. 預檢成功後,實際回應仍要過關

需要預檢,且沒有可重用許可
  ↓
OPTIONS 詢問來源/方法/標頭
  ├─ 不通過:這輪不送後續實際請求
  └─ 通過:送實際請求 → API 處理 → 檢查實際回應 CORS
                                      ├─ 符合:網頁可讀
                                      └─ 不符合:網頁不可讀,操作可能已完成

OPTIONS 回 204 只是狀態成功,不等於許可內容完整。PUT 還需要相符的方法許可,例如 Access-Control-Allow-Methods: PUT。預檢只完成前一階段,不能取代實際回應的 Allow-Origin 或適用的 credentials 許可。

不要只替成功回應加 CORS。API 的 401、500,以及閘道產生的錯誤回應,也需要依來源政策處理;否則瀏覽器的 CORS 訊息可能遮住上游真正的故障。

credentials 主要控制瀏覽器管理的 credentials,也影響回應的 Set-Cookie 是否被採用:

值 行為
same-origin 預設,只在同源請求使用
include 跨來源也可使用,仍受 Cookie 與瀏覽器政策限制
omit 不使用,即使是同源

Cookie session API 的前端可以寫:

const response = await fetch("https://api.example.test/me", {
  credentials: "include",
});

if (!response.ok) {
  throw new Error("HTTP " + response.status);
}

const me = await response.json();

這不保證 Cookie 存在、合格或登入成功。只是不能漏掉的前端條件。MDN:Request.credentials

對跨來源 include 模式,API 的許可需要:

Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Credentials: true

Allow-Origin: * 加上 Allow-Credentials: true 仍無效。也不能說「這次剛好沒 Cookie,所以星號可以」;CORS 判斷的是 credentials mode,不是只看本次 Cookie 是否存在。HttpOnly 不會修好這個組合。MDN:Allow-Credentials、Fetch:credentials 與來源分享規則

一般 GET 不會只因 include 就必然預檢。若需要預檢,普通預檢不帶登入 Cookie,但伺服器仍須在許可中允許後續 include 模式;真正 API 的認證與權限檢查保留在實際請求。

動態迷因(展開/收合)
看起來像自己人,仍要核對每一層許可;include、Cookie 條件與 API 的來源許可不能互相代替。 · 來源:GIPHY

9. HttpOnly、SameSite、Secure 各管哪裡?

Set-Cookie: session=demo-value; Path=/; Secure; HttpOnly; SameSite=Lax

這是示意,不是讓所有系統都照抄的登入設定。

條件 在檢查什麼
host/Domain Cookie 是否適用這次 API 主機;沒有 Domain 通常為 host-only
Path、到期時間 路徑是否相符,Cookie 是否仍有效
Secure 安全傳輸條件,正式環境使用 HTTPS
HttpOnly script 不能直接讀 Cookie 值
SameSite 站點情境是否允許自動帶 Cookie
瀏覽器政策 第三方 Cookie 是否受限制

HttpOnly 不阻止符合條件的 Cookie 隨 fetch 自動傳送。跨來源也不必然跨站;一般而言,上述 app 與 api 是不同 origin,但可屬於相同 schemeful site。site 比較 scheme 與可註冊網域,和 origin 的 hostname/port 比較不同。MDN:Site

明確跨站 fetch 時,SameSite=Lax 不會因 include 就放行。SameSite=None 必須搭配 Secure,而且第三方 Cookie 政策仍然有效。不要為了登入問題移除 HttpOnly,或不經評估就把所有 Cookie 改成 None。

Set-Cookie 本身也不能公開給前端 script;即使列入 Expose-Headers,仍無法透過 response.headers.get("Set-Cookie") 讀原文。Network 看見、瀏覽器儲存與 script 可讀,是不同範圍。MDN:Set-Cookie

10. Allow-Headers 和 Expose-Headers 別放反

假設 request 帶 X-Client-Version,response 回 X-Next-Cursor:

Access-Control-Allow-Headers: X-Client-Version
Access-Control-Expose-Headers: X-Next-Cursor

第一行允許送出去的 request header。第二行讓 script 讀回來的 response header,例如 response.headers.get("X-Next-Cursor")。body 能讀,不代表每個 header 都能讀;Network 面板的可見範圍也不等於 JavaScript。MDN:Expose-Headers

其餘常見標頭可以這樣回查:

標頭 誰設定 工作
Origin 瀏覽器 發起網頁的來源
Access-Control-Request-Method/Headers 瀏覽器預檢 預計使用的方法/非 safelisted 標頭名稱
Access-Control-Allow-Origin API 回應 允許這次來源
Access-Control-Allow-Methods/Headers API 預檢回應 允許需要確認的方法/標頭
Access-Control-Allow-Credentials API 適用回應 允許 include 模式分享回應
Access-Control-Expose-Headers API 實際回應 額外可供 script 讀取的回應標頭

11. 沒看到 OPTIONS,與 no-cors 是不同問題

Access-Control-Max-Age 快取的是預檢許可,不是 API body。符合已有許可的請求可能不再送 OPTIONS,瀏覽器也有自己的時間上限。新增未被允許的標頭,不能直接沿用舊許可。MDN:Max-Age

API 依 Origin 改變回應時,要處理快取的來源版本區分,例如在既有 Vary 裡加入 Origin。Vary: Origin 不授予 CORS 許可,也不表示私人資料可以公開快取。MDN:Vary

mode: "no-cors" 也不會把 JSON API 變成可讀。跨來源 opaque 回應對 script 顯示 status 0、body null,無法讀到原始內容。改用 text 再 JSON.parse 不會救回隱藏的 body;方法與標頭本身也受限制。MDN:Response.type

12. CORS 不會替你驗證使用者或防住所有 CSRF

來源許可、使用者認證與資料授權各自需要檢查。Origin 不是使用者身分;非瀏覽器程式可以自行構造標頭,不能只因 Origin 相符就允許私人資料。

CSRF 可以利用瀏覽器自動附帶的登入資訊,促成使用者沒打算做的操作。攻擊者不一定需要讀回應。因此沒有 CORS 許可,不等於寫入端點已防 CSRF。要依系統使用 CSRF token、來源檢查、Cookie 政策等合適防護。MDN:CSRF

13. 實務除錯順序

我會從 Network 找證據,不會先把所有星號加上去:

  1. 比較頁面 origin 與 API URL,也確認 proxy 或 redirect 是否改變目標。
  2. 有 OPTIONS 時,核對預計 method/headers 與預檢許可;不要只看 204。
  3. 有實際請求時,查 API 狀態、伺服器紀錄與實際回應的 CORS。寫入失敗訊息不等於沒有寫入。
  4. Cookie 沒帶上時,檢查儲存狀態、host/path、credentials、SameSite、HTTPS 與瀏覽器政策。
  5. body 正常但 header 讀不到,檢查 Expose-Headers 與禁止公開的標頭。
  6. 連閘道與錯誤回應一起看,不只檢查成功路徑。

如果 CORS 正確,HTTP 401/500 通常仍會讓 fetch 取得 Response,而不是只因狀態碼就 reject。範例裡的 response.ok 檢查不能省。反過來,catch 的 TypeError 本身也不足以辨認是 CORS 還是網路失敗。MDN:Using Fetch

開發 proxy 可讓瀏覽器看到同源請求,方便開發,但不能證明正式環境的跨來源設定正確。部署前要用真實瀏覽器測允許與拒絕來源、Cookie 行為,以及 script 的 body/header 可讀結果。

我學到什麼

  • 我會把送出請求、API 執行與網頁讀回應分開判斷,避免把 CORS 錯誤當成安全重送的理由。
  • include 是 credentials 模式,不能取代 Cookie 條件,也不能搭配萬用 Allow-Origin;HttpOnly 管的是直接讀取。
  • 我會先辨認標頭方向。Authorization 需要明確的 Allow-Headers,回應游標則需要 Expose-Headers。
  • 修正 CORS 後,認證、資料權限與 CSRF 仍要獨立驗證。

本文聚焦一般 HTTP API 的瀏覽器 fetch,不包含 WebSocket、特殊 origin、跨來源隔離或私有網路存取的完整規則。程式片段是前端與 HTTP 行為示意,不是完整的 production 認證實作。

外部參考資料