Notes
← All notes
·frontend·14 min

daily-soup-widget 從寫到上架(番外):embed widget 撞 CORS 的三個場景跟兩個 cache 雷

上下篇講設計選型跟 publish 踩雷,這篇講「如果哪天 build-time schedule 改成 runtime fetch」,瀏覽器會從哪幾個角度把 CORS 砸回來。三個 origin 的角色、為什麼 script 標籤不撞但 fetch 撞、preflight 怎麼自動長出來、credentials 跟 wildcard 為什麼不能並存、CDN 一個 header 沒設整個 widget 全壞。

#widget#side-projects#cors#preflight#cdn-cache

上篇講三個設計選型,其中有一段提到 build-time 預先算好 schedule「順帶把 CORS 一刀砍掉」。那是 side effect——當時選 build-time 是衝著確定性、零後端、離線可用三個動機,CORS 沒踩到只是運氣。

這篇是把那個伏筆挖開:如果哪一天我或別人想把同樣的 widget 改成 runtime fetch(每次打開頁面再去抓今天該顯示哪句),瀏覽器會從哪幾個角度把 CORS(Cross-Origin Resource Sharing,瀏覽器限制網頁從別的網域抓資料的安全機制)砸回來。

CORS 的文件 MDN 寫得很完整1,但 widget 這個情境有它特別的地方——同一份 JS 會被裝在幾百個不同網域的部落格裡,那「origin」到底算誰的?以及一個跨 origin 才會浮出來的 CDN cache 雷:response header 沒設好,第二個宿主拿到的 Access-Control-Allow-Origin 是第一個宿主的,整個 widget 在後續所有宿主上都會壞。

文章寫給「沒被 CORS 真的咬過、但想知道為什麼別人在罵」的人看。

本文導覽

三個 origin 在哪邊

一個 embed widget 跑起來的時候,瀏覽器會看到三個不同網域的東西在互動:

┌──────────────────────────────────────────────────┐
│  宿主網頁 (host page origin)                       │
│  https://alice.blog                              │
│                                                  │
│  ┌──────────────────────────────────────┐        │
│  │ <script src="...">                   │        │
│  │  從這個網域載入:                       │        │
│  │  https://unpkg.com/daily-soup-widget │ ← bundle origin
│  └──────────────────────────────────────┘        │
│                                                  │
│  載入後,widget 在這個頁面裡執行                     │
│  執行的時候如果要 fetch:                            │
│  fetch('https://soup.example.com/api/...')       │ ← API origin
│                                                  │
└──────────────────────────────────────────────────┘

三個網域:

  • host page origin——宿主部落格的網域。https://alice.blog。這是頁面 URL 的 origin,瀏覽器判斷 CORS 的時候用的就是這個。
  • bundle origin——widget JS 檔案存放的網域。如果 widget 是從 npm 上架後讓 CDN(像 unpkg、jsDelivr)自動 mirror 出來,bundle 來自 https://unpkg.com;如果 widget 作者自己架了 CDN,可能是 https://cdn.daily-soup.dev
  • API origin——widget 執行時,如果要打 API 拿資料,那個 API 的網域。本篇案例是 https://soup.example.com——假設我把 schedule 改成 runtime 才去抓。

CORS 只會擋一條箭頭:從 host page origin 用 fetch / XHR 去打 API origin。其他箭頭(包含 host 載入 bundle)跟 CORS 沒有關係——下一節解釋為什麼。

為什麼 script 標籤不撞 CORS 但 fetch 撞

最常見的誤解是:「我的 widget JS 是從 unpkg 載進來的,不就是 cross-origin 嗎?為什麼沒撞 CORS?」

因為 <script src="..."> 預設不走 CORS 檢查2

瀏覽器對 cross-origin 資源分兩類:

資源類型預設行為
<script><img><link rel="stylesheet"><iframe>直接載入,不檢查 CORS
fetch()XMLHttpRequest<script type="module">一律檢查 CORS

<script><img> 這類是歷史包袱——它們在 CORS 規範出現之前就存在,全部加上 CORS 檢查會打爆整個 web。所以瀏覽器留下一個「opt-in」開關:要做 CORS 檢查就加 crossorigin attribute:

<!-- 不檢查 CORS,JS 載得進來就能跑 -->
<script src="https://unpkg.com/daily-soup-widget/dist/embed.umd.js"></script>

<!-- 檢查 CORS,目的通常是想拿 error stack trace 或做 SRI 完整性驗證 -->
<script
  src="https://unpkg.com/daily-soup-widget/dist/embed.umd.js"
  crossorigin="anonymous"
></script>

fetch()XMLHttpRequest 是 CORS 規範後才設計的 API,沒有歷史包袱要顧,預設就走 CORS 檢查。

所以 widget 第一次撞 CORS 的時間點,永遠是「執行時想用 fetch 去打另一個網域」那一刻——而不是 widget 被載入那一刻。

場景一:schedule 改成 runtime fetch

把上篇的 build-time schedule 拿掉,改成 runtime 去 fetch:

// widget 執行時
const today = new Date().toISOString().split('T')[0];
const res = await fetch(`https://soup.example.com/schedule-zh.json`);
const schedule = await res.json();
const slug = schedule[today];

宿主 https://alice.blog 裝了這個 widget。打開頁面,console 立刻紅一片:

Access to fetch at 'https://soup.example.com/schedule-zh.json'
from origin 'https://alice.blog' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

這段錯誤訊息常常被誤解成「瀏覽器不讓 request 出去」。實際發生的事是這樣:

  1. 瀏覽器送出 request(會在 Network tab 看到 status 200)。
  2. server 回 response,但 response header 裡沒有 Access-Control-Allow-Origin(簡稱 ACAO)。
  3. 瀏覽器判定「server 沒授權給 alice.blog 讀」,把 response body 鎖起來不給 JS 看。
  4. await res.json() 那一行 throw 一個 TypeError。

換句話說 request 是真的有送出去、server 是真的有執行——只是「能不能被 JS 讀到」這件事被瀏覽器擋下來了。

修法是讓 server 在 response header 加上 ACAO。我的 schedule API 在 Vercel 上,vercel.json 寫一段:

{
  "headers": [
    {
      "source": "/schedule-zh.json",
      "headers": [
        { "key": "Access-Control-Allow-Origin", "value": "*" }
      ]
    }
  ]
}

* 在這個場景 OK 嗎?OK——schedule 是公開資料,不帶 cookie、不認身分,任何網域都該能讀。* 的意思就是「我授權給所有 origin」。

後面兩個場景會看到 * 不能用的情況。

場景二:按需 fetch 單句,preflight 自動長出來

90 天的 schedule 整包打包 ship 太大。改成「載 widget 時只 fetch 今天明天兩天,後面用到才現抓」:

async function getQuote(slug) {
  const res = await fetch(`https://soup.example.com/api/quote`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Widget-Version': '0.2.0',
    },
    body: JSON.stringify({ slug }),
  });
  return res.json();
}

Network tab 打開,發現每打一次 getQuote 都會看到兩個 request:

  1. OPTIONS /api/quote(status 204)
  2. POST /api/quote(status 200)

第一個 OPTIONS request 就是 preflight(預檢請求)——瀏覽器在真正送 request 之前,先發一個 OPTIONS 去問 server:「我等下想用 POST 方法、想帶 X-Widget-Version 這個 header,你接不接?」server 回 OK 才會送第二個真正的 request3

什麼情況會觸發 preflight

不是每個 cross-origin request 都會觸發。瀏覽器把 cross-origin request 分兩種3

  • simple request——不需要 preflight,直接送。條件嚴格:method 只能是 GET / HEAD / POST、不能帶非標準 header、Content-Type 只能是 application/x-www-form-urlencoded / multipart/form-data / text/plain
  • 不符合上面任一條——自動觸發 preflight。

上面那段 fetch 有兩個地方踩到:

  • Content-Type: application/json 不在 simple request 白名單。
  • X-Widget-Version 是自訂 header,整個就觸發 preflight。

server 端要在 OPTIONS response 回答兩件事:

{
  "headers": [
    {
      "source": "/api/(.*)",
      "headers": [
        { "key": "Access-Control-Allow-Origin", "value": "*" },
        { "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
        { "key": "Access-Control-Allow-Headers", "value": "Content-Type, X-Widget-Version" },
        { "key": "Access-Control-Max-Age", "value": "7200" }
      ]
    }
  ]
}

Access-Control-Allow-Methods 列「我接受哪些 method」、Access-Control-Allow-Headers 列「我接受哪些自訂 header」。少列一個就會被擋。

用 Max-Age 把 preflight cache 起來

每個 request 變兩次,latency 多一個 RTT(Round-Trip Time,請求從瀏覽器送到 server 再收回回應的來回時間),量大了會痛。Access-Control-Max-Age 告訴瀏覽器「同一個 origin + method + header 組合的 preflight 結果,可以 cache 幾秒」。在這段時間內後續同類 request 不會再發 OPTIONS。

但這個值在三家瀏覽器有自己的上限:

瀏覽器Max-Age 上限
Firefox86400 秒(24 小時)4
Chromium 系(Chrome / Edge),v76+7200 秒(2 小時)4
Safari(WebKit)600 秒(10 分鐘)5

設超過上限的部分會被截掉。所以設 86400 對 Chrome 來說等於 7200、對 Safari 來說等於 600。實務上設 7200 是「在 Chrome 拿到滿值、在 Safari 也不浪費」的折衷。

真的不想要 preflight 怎麼辦

兩條路:

  • 把 request 改成 simple request。Content-Type 改成 text/plainapplication/x-www-form-urlencoded,把 JSON body 改成 query string 或 form-encoded。能省 preflight 但要犧牲 JSON 的便利。
  • 把資訊塞 query stringX-Widget-Version 改成 ?widget_version=0.2.0,header 列表恢復乾淨。

小 widget 場景通常選後者——版本資訊放 URL 又不是機密。

場景三:曝光統計 endpoint,credentials 跟 wildcard 衝突

加一個曝光統計 endpoint:widget 偷送 POST 回報「今天哪句被誰看了」。「誰」希望帶 cookie 認證——這樣同一個讀者跨頁面瀏覽可以歸到同一個 session:

fetch('https://soup.example.com/api/track', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',   // 把 cookie 帶上
  body: JSON.stringify({ slug: 'quote-0042' }),
});

credentials: 'include' 的意思是「不管同源還是 cross-origin,都把 cookie 跟 HTTP authentication header 帶上去」。

server 沿用場景二的 vercel.json:

{ "key": "Access-Control-Allow-Origin", "value": "*" }

Console 紅了:

The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.

*credentials: include 不能並存6。原因是規範這樣定的:如果允許 wildcard 加 credentials,攻擊者放一個惡意網頁、誘導已登入的使用者拜訪,就可以用使用者的 cookie 去打目標 API、讀到回傳結果。* 等於「我不在乎是誰來」,而 credentials 等於「我要根據是誰來決定回什麼」——兩件事邏輯上衝突。

解法是 server 改成「echo 回 request 帶來的 Origin」:

// Vercel serverless function 寫法(不走 vercel.json 的靜態 header config)
export default function handler(req, res) {
  const origin = req.headers.origin;
  if (isAllowed(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');   // 為什麼要這行?下一節講
  }
  // ...
}

兩個動作:

  • 從 request header 拿 Origin,echo 回 response 的 Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials: true。少這行的話 credentials 還是被擋。

「echo 哪些 origin」就是個維運問題了——白名單寫死、查資料庫、按部落格作者註冊狀況決定,三條路各有取捨。widget 的 side project 情境通常維護不起白名單,比較合理的做法是不做需要 credentials 的功能,改用 anonymous 統計(從 request 帶來的資訊推匿名 ID,不依賴 cookie)。

兩個 cache 雷:CDN 的 Vary 跟 preflight 的 Max-Age

前兩個場景都假設 server 是「每個 request 都跑一遍邏輯」。如果 server 前面有 CDN cache(像 Vercel Edge Cache、CloudFront、Cloudflare)就會出新的雷。

雷一:CDN 把第一個宿主的 ACAO cache 給後面所有宿主

場景三那段 server code 給每個宿主 echo 回各自的 Origin。但 CDN 的 cache key 預設只看 URL、不看 request header。

第一個拜訪者來自 https://alice.blog

  1. CDN 沒 cache,回源 server。
  2. server 回 Access-Control-Allow-Origin: https://alice.blog
  3. CDN 把這份 response 存起來,cache key 是 URL /api/track

第二個拜訪者來自 https://bob.dev

  1. CDN 看到 URL /api/track 有 cache,直接回。
  2. response header 是 Access-Control-Allow-Origin: https://alice.blog
  3. 瀏覽器看到 bob.dev 不等於 alice.blog,擋掉。

從第二個宿主開始所有人都壞。

修法是告訴 CDN「這個 response 的內容會根據 request 的 Origin header 變化,請把 Origin 也納入 cache key」7

Vary: Origin

CDN 看到 Vary: Origin 之後會分別 cache (URL, alice.blog)(URL, bob.dev) 兩份。

這個雷的特性是「在自己一台電腦上開發看不到」——只有當有兩個以上不同 origin 的宿主開始用,第二個拜訪者才會撞牆。所以開發階段測不出來,上線之後讀者開始踩,回報又會被誤判成「我的瀏覽器壞了」或「網路問題」。

雷二:preflight cache 不會跨 origin 共用

場景二講的 Access-Control-Max-Age 還有一個容易忽略的細節:cache 是「瀏覽器自己存的」,不是 CDN 存的。

意思是同一個讀者瀏覽 alice.blog 兩次,第二次就不會發 preflight——這個 cache 是瀏覽器看著 origin + method + header 組合存的,跟 server / CDN 都無關。但如果讀者跳到 bob.dev,是另一個 origin,preflight cache 不會共用——又要重發一次。

這對 widget 是潛在問題:每個宿主第一次拜訪都會吃一次 preflight RTT。讀者覺得「widget 第一次出現比較慢」就是這個。沒有辦法消滅,只能用「把 request 改成 simple request」這條路繞掉。

回到 widget:每多一個功能要付多少 CORS 成本

把這篇講的東西串回 widget 的功能決策:

想加的功能CORS 會咬到哪裡server 要設client 要加
runtime fetch 公開 schedule場景一ACAO: *(無)
按需 fetch 帶 JSON body場景二(preflight)Allow-Methods / Allow-Headers / Max-Age接受多一次 OPTIONS RTT,或改 simple request
自訂 header 識別版本場景二(preflight)Allow-Headers 列上同上,或塞 query string 繞掉
曝光統計帶 cookie 認證場景三echo Origin + Allow-Credentials + Varycredentials: 'include'
API 前面有 CDN cachecache 雷一Vary: Origin(無)
跨宿主第一次拜訪快cache 雷二Max-Age 設到 7200(Chrome 上限)改 simple request 才能完全繞掉

把每一行加起來才是「runtime fetch 模型」的完整維運成本——server 端要寫 origin 白名單跟 echo 邏輯、要記得設 Vary、要監控 preflight 拒絕率;client 端要在 simple request 跟功能性之間挑、要在所有測試環境裡都驗 cross-origin 行為。

技術主要改善指標結果
build-time schedule(上篇選用維運成本server 端零維運,client 端零 CORS 設定
runtime fetch + ACAO *request RTT多一次往 API origin 的 round trip,無 preflight
runtime fetch + JSON bodyrequest RTT每個 cold endpoint 多一次 OPTIONS preflight;Chrome 7200s cache 後消失
runtime fetch + credentialsserver 邏輯複雜度從靜態 header 升級到動態 echo + 白名單維護
CDN cache 加 Vary: Origincache 命中率命中率降低(每個 origin 一份 cache),換來正確性
改 simple request 繞 preflight開發體驗失去 JSON body / 自訂 header;換到零 preflight overhead

回頭看上篇的決策「build-time 預先算好 schedule」——那個選擇砍掉的不只是「一個 API endpoint 的維護成本」,是上面整張表的全部。當時是衝著「過去日期不變」這個產品定義選 build-time,CORS 那一刀只是順手砍下去的紅利。

下次設計新的 embed widget 時,這個對照表可以拿來反推:「我這個功能真的需要嗎?需要的話我準備好付這幾項成本嗎?」如果答案是「不太想付」,build-time 還是該優先嘗試的方向。


參考資料

Footnotes

  1. Cross-Origin Resource Sharing (CORS) — MDN — CORS 完整規範解說,包含 Access-Control-Allow-* header 家族與 preflight 流程。

  2. The crossorigin attribute — MDN — 解釋 <script> / <img> / <link> 預設不走 CORS,以及 crossorigin attribute 如何 opt-in。

  3. Preflight request — MDN HTTP glossary — 列出觸發 preflight 的條件(非 simple request、自訂 header、特定 Content-Type),以及 OPTIONS request 完整流程。 2

  4. Access-Control-Max-Age — MDN — 列出 Firefox 86400 秒、Chromium(v76 起)7200 秒兩家瀏覽器的上限,並附 Firefox nsCORSListenerProxy.cpp 與 Chromium preflight_result.cc source link。 2

  5. WebKit CrossOriginPreflightResultCache.cppmaxPreflightCacheTimeout = 600_s — Safari 的 preflight cache 上限直接寫在 WebKit source code,註解寫「短到足以避免換到安全網路後仍命中污染過的 cache」。

  6. Access-Control-Allow-Credentials — MDN — 解釋為什麼 Access-Control-Allow-Origin: *credentials: include 不能並存。

  7. Vary — MDN — 解釋 cache 如何根據 Vary 列出的 request header 分桶;CDN 對 cross-origin endpoint 需設 Vary: Origin 避免 ACAO 混淆。