daily-soup-widget 從寫到上架(番外):embed widget 撞 CORS 的三個場景跟兩個 cache 雷
上下篇講設計選型跟 publish 踩雷,這篇講「如果哪天 build-time schedule 改成 runtime fetch」,瀏覽器會從哪幾個角度把 CORS 砸回來。三個 origin 的角色、為什麼 script 標籤不撞但 fetch 撞、preflight 怎麼自動長出來、credentials 跟 wildcard 為什麼不能並存、CDN 一個 header 沒設整個 widget 全壞。
上篇講三個設計選型,其中有一段提到 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 在哪邊
- 為什麼 script 標籤不撞 CORS 但 fetch 撞
- 場景一:schedule 改成 runtime fetch
- 場景二:按需 fetch 單句,preflight 自動長出來
- 場景三:曝光統計 endpoint,credentials 跟 wildcard 衝突
- 兩個 cache 雷:CDN 的 Vary 跟 preflight 的 Max-Age
- 回到 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 出去」。實際發生的事是這樣:
- 瀏覽器送出 request(會在 Network tab 看到 status 200)。
- server 回 response,但 response header 裡沒有
Access-Control-Allow-Origin(簡稱 ACAO)。 - 瀏覽器判定「server 沒授權給
alice.blog讀」,把 response body 鎖起來不給 JS 看。 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:
OPTIONS /api/quote(status 204)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 上限 |
|---|---|
| Firefox | 86400 秒(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/plain或application/x-www-form-urlencoded,把 JSON body 改成 query string 或 form-encoded。能省 preflight 但要犧牲 JSON 的便利。 - 把資訊塞 query string。
X-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:
- CDN 沒 cache,回源 server。
- server 回
Access-Control-Allow-Origin: https://alice.blog。 - CDN 把這份 response 存起來,cache key 是 URL
/api/track。
第二個拜訪者來自 https://bob.dev:
- CDN 看到 URL
/api/track有 cache,直接回。 - response header 是
Access-Control-Allow-Origin: https://alice.blog。 - 瀏覽器看到
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 + Vary | credentials: 'include' |
| API 前面有 CDN cache | cache 雷一 | 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 body | request RTT | 每個 cold endpoint 多一次 OPTIONS preflight;Chrome 7200s cache 後消失 |
| runtime fetch + credentials | server 邏輯複雜度 | 從靜態 header 升級到動態 echo + 白名單維護 |
CDN cache 加 Vary: Origin | cache 命中率 | 命中率降低(每個 origin 一份 cache),換來正確性 |
| 改 simple request 繞 preflight | 開發體驗 | 失去 JSON body / 自訂 header;換到零 preflight overhead |
回頭看上篇的決策「build-time 預先算好 schedule」——那個選擇砍掉的不只是「一個 API endpoint 的維護成本」,是上面整張表的全部。當時是衝著「過去日期不變」這個產品定義選 build-time,CORS 那一刀只是順手砍下去的紅利。
下次設計新的 embed widget 時,這個對照表可以拿來反推:「我這個功能真的需要嗎?需要的話我準備好付這幾項成本嗎?」如果答案是「不太想付」,build-time 還是該優先嘗試的方向。
參考資料
Footnotes
-
Cross-Origin Resource Sharing (CORS) — MDN — CORS 完整規範解說,包含
Access-Control-Allow-*header 家族與 preflight 流程。 ↩ -
The crossorigin attribute — MDN — 解釋
<script>/<img>/<link>預設不走 CORS,以及crossoriginattribute 如何 opt-in。 ↩ -
Preflight request — MDN HTTP glossary — 列出觸發 preflight 的條件(非 simple request、自訂 header、特定 Content-Type),以及 OPTIONS request 完整流程。 ↩ ↩2
-
Access-Control-Max-Age — MDN — 列出 Firefox 86400 秒、Chromium(v76 起)7200 秒兩家瀏覽器的上限,並附 Firefox
nsCORSListenerProxy.cpp與 Chromiumpreflight_result.ccsource link。 ↩ ↩2 -
WebKit
CrossOriginPreflightResultCache.cpp—maxPreflightCacheTimeout = 600_s— Safari 的 preflight cache 上限直接寫在 WebKit source code,註解寫「短到足以避免換到安全網路後仍命中污染過的 cache」。 ↩ -
Access-Control-Allow-Credentials — MDN — 解釋為什麼
Access-Control-Allow-Origin: *與credentials: include不能並存。 ↩ -
Vary — MDN — 解釋 cache 如何根據 Vary 列出的 request header 分桶;CDN 對 cross-origin endpoint 需設
Vary: Origin避免 ACAO 混淆。 ↩