Pagefind 搜尋在嚴格的 CSP 底下卡住了 — worker 只看載入它當次回應上的 header,而 304 不會帶上 CSP
Vercel 上的 Pagefind 搜尋因為 CSP 缺少 wasm-unsafe-eval 而卡住了。修正之後,原先造訪過本站的讀者,因為瀏覽器已經快取了 worker,重新驗證時收到的 304 並不帶有 CSP,因此會沿用修改前的政策。直到我把政策的雜湊寫進 worker 檔案,這個問題才完全解決。

本頁目錄
引言
我在自己的部落格搜尋「claude」,搜尋框一直停在 「Searching for claude…」,沒有結果,也沒有錯誤。從兩個月前我為網站加上 Content-Security-Policy(CSP)之後就一直是這樣。這段期間,每個試著搜尋的讀者看到的都是同一個空白的搜尋框。
CSP 的 script-src 裡沒有 'wasm-unsafe-eval',所以瀏覽器拒絕編譯 Pagefind 在 worker 裡執行的 WebAssembly。編譯失敗的錯誤只出現在 DevTools 的主控台,搜尋則一直等不到結果。

加上 'wasm-unsafe-eval' 之後,只有新的訪客能夠正常使用搜尋功能。舊的訪客如果用的是已經快取了 worker 的瀏覽器,搜尋還是會卡住。這是因為瀏覽器會向 Vercel 確認快取的 worker 是否仍是最新的,而 Vercel 回覆「檔案沒有變更」的 304 回應裡沒有附上 CSP header。瀏覽器因此繼續使用快取裡的舊政策。
現在我把政策的雜湊寫進了 worker 檔案,所以只要政策一改,每個瀏覽器在下一次載入時都會拿到新的政策。
Pagefind 如何進行搜尋?
Pagefind 是一個搜尋函式庫,專門給靜態網站使用。建置時,它會為產生的 HTML 建立索引,再把索引連同一個小型的 JavaScript 與 WebAssembly(WASM)執行環境一起放到網站上。
在 Pagefind 1.5 中,搜尋是在一個專用的 worker 裡進行的。worker 在頁面的主執行緒之外執行,所以查詢時頁面不會卡住。這個 worker 由 pagefind.js 在第一次查詢時啟動:
workerUrl = `${basePath}pagefind-worker.js`;sharedWorker = new Worker(workerUrl);接著 worker 會下載 WASM 並編譯它。從 URL 載入的專用 worker 套用的是載入它當次回應上的 CSP,而不是啟動它的那個頁面的政策。MDN 直接寫明了這一點。唯一的例外是從 blob: 或 data: URL 建立的 worker,這種 worker 會繼承建立者的政策。
/pagefind/pagefind-worker.js 回應上的那一份決定,而那一份裡沒有 'wasm-unsafe-eval'。因此,搜尋能不能運作,要看 /pagefind/pagefind-worker.js 回應上的 header。在這個部落格上,vercel.json 的 headers 裡有一條 source 為 /(.*) 的規則(以下稱為全站規則)。/(.*) 會符合所有路徑,所以每個頁面和 worker 拿到的都是這條規則設定的同一個 CSP header:
{ "headers": [ { "source": "/(.*)", "headers": [ { "key": "Content-Security-Policy", "value": "default-src 'self'; … script-src 'self' …" } ] } ]}這條規則的 CSP 裡沒有 'wasm-unsafe-eval',所以 worker 收到的政策和頁面一樣,都不允許編譯 WASM。要修正,就得改這條全站規則。
為什麼 CSP 會擋下 WASM 的編譯?
問題出在 CSP 的 script-src。它只允許兩種腳本:
'self':只允許來自本站網域的腳本檔案,其他網域的第三方腳本一律禁止。- 雜湊:寫在 HTML 裡的內嵌腳本預設全部禁止,只允許內容符合特定雜湊的內嵌腳本。
內嵌腳本預設禁止,是因為透過 XSS(跨站腳本攻擊)注入的內嵌腳本,看起來和本站自己的一模一樣。所以政策裡列了六個雜湊,各自對應本站自己的一段內嵌腳本:
script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' …script-src 管的不只是腳本,也包括在執行時才產生的程式碼,例如 eval() 執行的字串和 WebAssembly 編譯的位元組。這類程式碼無法像腳本檔案或雜湊那樣事先檢查,所以 CSP 預設全部擋下。如果沒有 'unsafe-eval' 或範圍更窄的 'wasm-unsafe-eval',WebAssembly.instantiate() 就會拋出例外。我在搜尋框卡住時打開 DevTools,看到的正是這個由 pagefind-worker.js 記錄下來的錯誤:
Failed to load the Pagefind WASM:CompileError: WebAssembly.instantiate(): Compiling or instantiating WebAssembly module violates the following Content Security policy directive because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' …".這個錯誤不會傳到 worker 外面,因此搜尋框等待的 Promise 永遠不會完成,搜尋框也就一直顯示「Searching for claude…」。頁面上也沒有任何提示告訴讀者發生了錯誤。
'wasm-unsafe-eval' 要放在哪裡?
'wasm-unsafe-eval' 只允許頁面或 worker 編譯並實例化 WebAssembly,除此之外什麼都不允許。Pagefind 的託管說明文件也指出,Pagefind 需要的就是 'wasm-unsafe-eval' 這個關鍵字。
我當初考慮了兩個可以放 'wasm-unsafe-eval' 的地方:
- 全站規則:套用到所有路徑,包括頁面和 Pagefind 的檔案。
/pagefind/*的規則:只套用到路徑以/pagefind/開頭的檔案,也就是 Pagefind 自己的檔案。
我選了全站規則,因為只加一條 /pagefind/* 的規則,worker 還是會被全站規則的政策擋下。
如果另外加上 /pagefind/* 的規則,worker 的路徑會同時符合兩條規則,因此收到兩個 Content-Security-Policy header。另一個在 Vercel 上使用 Pagefind 的網站就遇過這個情況 (useAvalon/Avalon#125)。瀏覽器會同時套用它收到的每一份政策,編譯必須通過所有政策才能進行。/pagefind/* 的政策允許編譯,但全站規則的政策仍然會拒絕它。
/pagefind/* 的規則,也只會多加一份政策,原本全站規則的那一份政策還是會一起被套用。如果要讓 /pagefind/* 的規則行得通,就得在全站規則的 source 裡寫一個負向先行斷言(negative lookahead)的正規表示式,把 /pagefind/ 排除在外。相比之下,在每個頁面都加上 'wasm-unsafe-eval' 不用大費周章,而且幾乎沒有任何代價,因為攻擊者得先有辦法在頁面上執行 JavaScript 去呼叫 WebAssembly.instantiate()。而能做到這一步的攻擊者,本來就能直接用 JavaScript 去做任何事,根本用不著特地使用 WASM。
這個部落格的 CSP 政策並不是手寫的,而是腳本自動產生出來的。scripts/compute-csp-hashes.ts 會為建置結果中的內嵌腳本計算雜湊,再把 header 寫進 vercel.json;只要 vercel.json 裡的 header 和建置結果算出的雜湊不一致,pnpm check 就會失敗。因此,我把 'wasm-unsafe-eval' 加進了產生政策的腳本:
const scriptSrc = `script-src 'self' 'wasm-unsafe-eval' ${scriptHashes.map((h) => `'${h}'`).join(" ")}`;// 修改前:script-src 'self' ${…}為什麼部署完並重新載入之後,搜尋還是卡住?
部署之後,worker 的回應已經帶著新的政策。但在我的瀏覽器裡,就算重新整理頁面之後搜尋,搜尋結果還是卡在「Searching for claude…」無法搜尋。最後在 Chrome 裡點選「Empty Cache and Hard Reload」(清除快取並強制重新載入)才解決了問題。打開 DevTools 之後,對重新載入按鈕按右鍵就會出現這個選項。下面的截圖是 Chrome 的日文介面。


worker 的回應帶著 Cache-Control: public, max-age=0, must-revalidate,意思是瀏覽器可以快取這個檔案,但每次使用前都必須先向伺服器確認它是否仍是最新的。確認的方式是把快取檔案的 ETag 放進 If-None-Match 送給伺服器。
修正只改了 header,檔案一個位元組都沒有變。因此 ETag 也沒有變,Vercel 回傳了 304 Not Modified。我用 curl 重現了這個請求:
輸入curl -sI -H 'If-None-Match: "8ff5a7a776be59b860655a35c70c008d"' https://oharu121.com/pagefind/pagefind-worker.js | grep -iE "^HTTP|content-security|cache-control|etag"輸出HTTP/2 304cache-control: public, max-age=0, must-revalidateetag: "8ff5a7a776be59b860655a35c70c008d"HTTP 快取的規範 RFC 9111 規定,瀏覽器收到 304 時,要用 304 裡的 header 更新快取檔案原本存下的 header。所以只要 304 裡有 CSP header,Chromium 就會用它取代舊的政策。但是這個 304 沒有帶 Content-Security-Policy,所以快取裡那份舊的政策沒有被換掉。
瀏覽器會繼續使用最初存下來的政策,直到快取的檔案被淘汰,或是讀者執行強制重新載入為止。
把政策的雜湊寫進 worker
我考慮了兩種解決方法:
- 在
/pagefind/*加上Cache-Control: no-store,禁止瀏覽器快取 Pagefind 的檔案。這樣每次載入都會拿到帶著完整 header 的 200,代價是每個有人搜尋的頁面都要重新下載一次打包檔。 - 每次政策改變時,就改變 worker 檔案的位元組,讓它的 ETag 跟著變。只要政策不變,就沒有任何成本。
我選了改變位元組的做法。
現在每次建置時,在 Pagefind 輸出 worker 之後,建置流程會再把 CSP 政策的雜湊寫進 worker 開頭的註解。這個雜湊取的是政策 SHA-256 的前 16 個十六進位字元:
const STAMP = /^\/\* csp:[0-9a-f]+ \*\/\n/;// …const stamp = await cspStamp();await writeFile(WORKER, `/* csp:${stamp} */\n` + source.replace(STAMP, ""));/* csp:826a419f579366b8 */const pagefind_version="1.5.2";…當 CSP 政策沒有改變時,寫入的雜湊註解也會保持不變。所以檔案的位元組完全相同,瀏覽器也不會重新下載 pagefind-worker.js。
這個部落格還有另一個 worker,也就是負責離線快取的 Service Worker(/sw.js)。同樣會遇到 CSP 政策不更新的問題。因此,我把同一個雜湊也寫進了 /sw.js 的版本號,這樣一來只要 CSP 政策改變了便會重新下載 /sw.js。
部署之後,用舊的 ETag 查詢會拿到新的檔案:
輸入curl -sI -H 'If-None-Match: "8ff5a7a776be59b860655a35c70c008d"' https://oharu121.com/pagefind/pagefind-worker.js | grep -iE "^HTTP|etag"輸出HTTP/2 200etag: "475b8920f22a66ecfaf009a83edafc7e"因為檔案的內容已經改變,舊的 ETag 對不上了,所以伺服器回的不再是 304,而是帶著新檔案和新 CSP header 的 200。快取了舊 worker 的瀏覽器在下一次一般載入時,就會把快取換成新的檔案和含有 'wasm-unsafe-eval' 的政策,搜尋也就恢復正常了。
總結
這個部落格的搜尋已經恢復正常,新訪客和回訪的讀者都能使用。回顧這次的經過:
- Pagefind 的 WASM 是在 worker 裡編譯的,而 worker 套用的是載入它當次回應上的 CSP 政策。所以
'wasm-unsafe-eval'必須加在全站規則上。就算另外加上一條/pagefind/*規則也只會多一份政策,原本全站規則那份會擋下編譯的政策還是會一起套用。 - 只載入頁面的 CSP 檢查抓不到這個錯誤,因為 WASM 要等到第一次搜尋時才會編譯。要確認搜尋能用,就得實際搜尋一次。
- 在 Vercel 上只改 header 的話,已經快取 worker 的瀏覽器看不到這個變更,因為重新驗證時收到的 304 不帶 CSP。把政策的雜湊寫進檔案,讓檔案跟著政策一起變,每個瀏覽器才會拿到新的政策。



