Pagefind 搜尋在嚴格的 CSP 底下卡住了 — worker 只看載入它當次回應上的 header,而 304 不會帶上 CSP

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

近黑色卡片上的白色 pagefind 標誌與小寫字樣,標誌是一個包著指針的橢圓形
本頁目錄

引言

我在自己的部落格搜尋「claude」,搜尋框一直停在 「Searching for claude…」,沒有結果,也沒有錯誤。從兩個月前我為網站加上 Content-Security-Policy(CSP)之後就一直是這樣。這段期間,每個試著搜尋的讀者看到的都是同一個空白的搜尋框。

CSP 的 script-src 裡沒有 'wasm-unsafe-eval',所以瀏覽器拒絕編譯 Pagefind 在 worker 裡執行的 WebAssembly。編譯失敗的錯誤只出現在 DevTools 的主控台,搜尋則一直等不到結果。

搜尋框顯示「Searching for claude…」,DevTools 主控台列出四個錯誤,每個都寫著「Failed to load the Pagefind WASM」以及與 Content Security Policy 有關的 CompileError

頁面看起來像是搜尋還在進行中。只有主控台用紅字說明了 WASM 被拒絕。

加上 '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 在第一次查詢時啟動:

pagefind.js(Pagefind 1.5.2,節錄,已將壓縮過的程式碼重新排版)
workerUrl = `${basePath}pagefind-worker.js`;
sharedWorker = new Worker(workerUrl);

接著 worker 會下載 WASM 並編譯它。從 URL 載入的專用 worker 套用的是載入它當次回應上的 CSP,而不是啟動它的那個頁面的政策。MDN 直接寫明了這一點。唯一的例外是從 blob: 或 data: URL 建立的 worker,這種 worker 會繼承建立者的政策。

Pagefind 的 worker 從哪裡取得 Content-Security-Policyvercel.json 裡 source 為 /(.*) 的規則,設定了一份 script-src 裡沒有 wasm-unsafe-eval 的 Content-Security-Policy。兩個箭頭把這份政策分成兩份送出,一份隨頁面的回應送出,另一份隨載入 worker 當次的回應送出。頁面的卡片裡,pagefind.js 用 new Worker() 啟動 worker。worker 的卡片裡,pagefind-worker.js 會下載 Pagefind 的引擎,也就是用 Rust 寫成、編譯成 WASM 的程式,再呼叫 WebAssembly.instantiate(),結果被 worker 那一份政策拒絕。vercel.jsonsource: "/(.*)" → script-src 'self' …✗ 沒有 'wasm-unsafe-eval'隨頁面的回應送出隨載入 worker 當次的回應送出頁面:oharu121.com/Content-Security-Policy頁面回應上的那一份pagefind.js在頁面上執行Worker:/pagefind/pagefind-worker.jsContent-Security-Policy載入 worker 當次回應上的那一份pagefind-worker.js負責載入並執行 WASM 的 JSnew Worker()下載 WASMWebAssembly.instantiate()Pagefind 的引擎:用 Rust 寫成,編譯成 WASM✗ 被拒絕:沒有 'wasm-unsafe-eval'判斷能否編譯的是 worker 回應上的那一份,裡面沒有 'wasm-unsafe-eval'
每個回應都帶著同一份政策的副本。能不能編譯,由 /pagefind/pagefind-worker.js 回應上的那一份決定,而那一份裡沒有 'wasm-unsafe-eval'。

因此,搜尋能不能運作,要看 /pagefind/pagefind-worker.js 回應上的 header。在這個部落格上,vercel.json 的 headers 裡有一條 source 為 /(.*) 的規則(以下稱為全站規則)。/(.*) 會符合所有路徑,所以每個頁面和 worker 拿到的都是這條規則設定的同一個 CSP header:

vercel.json(節錄)
{
"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。它只允許兩種腳本:

  1. 'self':只允許來自本站網域的腳本檔案,其他網域的第三方腳本一律禁止。
  2. 雜湊:寫在 HTML 裡的內嵌腳本預設全部禁止,只允許內容符合特定雜湊的內嵌腳本。

內嵌腳本預設禁止,是因為透過 XSS(跨站腳本攻擊)注入的內嵌腳本,看起來和本站自己的一模一樣。所以政策裡列了六個雜湊,各自對應本站自己的一段內嵌腳本:

Content-Security-Policy(節錄,修正前)
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' 的地方:

  1. 全站規則:套用到所有路徑,包括頁面和 Pagefind 的檔案。
  2. /pagefind/* 的規則:只套用到路徑以 /pagefind/ 開頭的檔案,也就是 Pagefind 自己的檔案。

我選了全站規則,因為只加一條 /pagefind/* 的規則,worker 還是會被全站規則的政策擋下。

如果另外加上 /pagefind/* 的規則,worker 的路徑會同時符合兩條規則,因此收到兩個 Content-Security-Policy header。另一個在 Vercel 上使用 Pagefind 的網站就遇過這個情況 (useAvalon/Avalon#125)。瀏覽器會同時套用它收到的每一份政策,編譯必須通過所有政策才能進行。/pagefind/* 的政策允許編譯,但全站規則的政策仍然會拒絕它。

兩條規則,兩個 Content-Security-Policy header左邊的 vercel.json 有一條套用到 /(.*) 的全站規則,它的 script-src 沒有 wasm-unsafe-eval;另一條較窄的規則套用到 /pagefind/(.*),script-src 裡有 wasm-unsafe-eval。兩條規則都符合 worker 的路徑,所以 /pagefind/pagefind-worker.js 的回應帶著兩個 Content-Security-Policy header。下方顯示全站規則的政策拒絕 WebAssembly.instantiate(),/pagefind/ 規則的政策則允許編譯。瀏覽器會同時套用兩份政策,所以編譯還是被擋下了。vercel.json/pagefind/pagefind-worker.js 的回應source: "/(.*)"script-src 'self' …符合Content-Security-Policyscript-src 'self' …source: "/pagefind/(.*)"script-src 'self' 'wasm-unsafe-eval'符合Content-Security-Policyscript-src 'self' 'wasm-unsafe-eval'✗ 全站規則的政策拒絕 WebAssembly.instantiate()✓ /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' 加進了產生政策的腳本:

scripts/compute-csp-hashes.ts
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 的日文介面。

Chrome 的重新載入選單,列出三個項目:一般重新載入(⌘R)、強制重新載入(⇧⌘R),以及清除快取並強制重新載入

搜尋框中輸入了「clau」,列出多篇文章的搜尋結果,摘要中的「Claude」被標示出來

執行「Empty Cache and Hard Reload」之後就能正常搜尋了。

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 304
cache-control: public, max-age=0, must-revalidate
etag: "8ff5a7a776be59b860655a35c70c008d"

HTTP 快取的規範 RFC 9111 規定,瀏覽器收到 304 時,要用 304 裡的 header 更新快取檔案原本存下的 header。所以只要 304 裡有 CSP header,Chromium 就會用它取代舊的政策。但是這個 304 沒有帶 Content-Security-Policy,所以快取裡那份舊的政策沒有被換掉。

瀏覽器會繼續使用最初存下來的政策,直到快取的檔案被淘汰,或是讀者執行強制重新載入為止。

只改 header 時,瀏覽器重新驗證拿到的是 304瀏覽器快取和 Vercel 之間的往來。第一次造訪時,瀏覽器請求 /pagefind/pagefind-worker.js,收到帶著 ETag 8ff5a7a7 和沒有 wasm-unsafe-eval 的 CSP 的 200,並把檔案連同這些 header 存下來。之後的部署改了 CSP header 但沒有改檔案,所以 ETag 也沒變。下一次載入時,瀏覽器送出 If-None-Match 8ff5a7a7,Vercel 回傳只有 cache-control 和 etag 的 304,裡面沒有 Content-Security-Policy。存下來的舊政策繼續生效,所以編譯還是被拒絕。瀏覽器快取Vercel第一次造訪GET /pagefind/pagefind-worker.js200 · ETag 8ff5a7a7… · 沒有 'wasm-unsafe-eval' 的 CSP瀏覽器把檔案連同這些 header 存了下來部署:CSP header 改了,檔案沒有改,所以 ETag 仍然是 8ff5a7a7…下一次載入If-None-Match: 8ff5a7a7…304 · 只有 cache-control 和 etag,沒有 Content-Security-Policy✗ 存下來的舊 CSP 繼續生效,所以編譯還是被拒絕
只改 header 並不會改變 ETag,而 304 也沒有帶來新的 CSP 政策。

把政策的雜湊寫進 worker

我考慮了兩種解決方法:

  1. 在 /pagefind/* 加上 Cache-Control: no-store,禁止瀏覽器快取 Pagefind 的檔案。這樣每次載入都會拿到帶著完整 header 的 200,代價是每個有人搜尋的頁面都要重新下載一次打包檔。
  2. 每次政策改變時,就改變 worker 檔案的位元組,讓它的 ETag 跟著變。只要政策不變,就沒有任何成本。

我選了改變位元組的做法。

現在每次建置時,在 Pagefind 輸出 worker 之後,建置流程會再把 CSP 政策的雜湊寫進 worker 開頭的註解。這個雜湊取的是政策 SHA-256 的前 16 個十六進位字元:

scripts/stamp-pagefind-csp.ts
const STAMP = /^\/\* csp:[0-9a-f]+ \*\/\n/;
// …
const stamp = await cspStamp();
await writeFile(WORKER, `/* csp:${stamp} */\n` + source.replace(STAMP, ""));
pagefind-worker.js(部署後的開頭幾行)
/* 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 200
etag: "475b8920f22a66ecfaf009a83edafc7e"

因為檔案的內容已經改變,舊的 ETag 對不上了,所以伺服器回的不再是 304,而是帶著新檔案和新 CSP header 的 200。快取了舊 worker 的瀏覽器在下一次一般載入時,就會把快取換成新的檔案和含有 'wasm-unsafe-eval' 的政策,搜尋也就恢復正常了。

總結

這個部落格的搜尋已經恢復正常,新訪客和回訪的讀者都能使用。回顧這次的經過:

  1. Pagefind 的 WASM 是在 worker 裡編譯的,而 worker 套用的是載入它當次回應上的 CSP 政策。所以 'wasm-unsafe-eval' 必須加在全站規則上。就算另外加上一條 /pagefind/* 規則也只會多一份政策,原本全站規則那份會擋下編譯的政策還是會一起套用。
  2. 只載入頁面的 CSP 檢查抓不到這個錯誤,因為 WASM 要等到第一次搜尋時才會編譯。要確認搜尋能用,就得實際搜尋一次。
  3. 在 Vercel 上只改 header 的話,已經快取 worker 的瀏覽器看不到這個變更,因為重新驗證時收到的 304 不帶 CSP。把政策的雜湊寫進檔案,讓檔案跟著政策一起變,每個瀏覽器才會拿到新的政策。

參考連結

分享這篇文章