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

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

- Source: https://oharu121.com/zh-tw/blog/pagefind-search-csp-worker-wasm-unsafe-eval-vercel-304/
- Published: 2026-10-31T23:56:08+09:00
- Tags: Pagefind, CSP, Web Worker, WebAssembly, HTTP, Vercel

---
## 引言

我在自己的部落格搜尋「claude」，搜尋框一直停在 **「Searching for claude…」**，沒有結果，也沒有錯誤。從兩個月前我[為網站加上 Content-Security-Policy（CSP）](/zh-tw/blog/astro-vercel-security-csp-service-worker-hash/)之後就一直是這樣。這段期間，每個試著搜尋的讀者看到的都是同一個空白的搜尋框。

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

*Image: 搜尋框顯示「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 為什麼使用 WebAssembly？**
>
> 為了節省流量，Pagefind 把索引切成壓縮過的二進位小區塊，查詢時只下載需要的那幾塊。解讀這些區塊的引擎是用 Rust 寫成，再編譯成 WebAssembly（WASM）。WASM 是瀏覽器能和 JavaScript 一起執行的二進位格式。詳細的設計可以參考 [CloudCannon 發表 Pagefind 的文章](https://cloudcannon.com/blog/introducing-pagefind/)。

在 Pagefind 1.5 中，搜尋是在一個專用的 worker 裡進行的。worker 在頁面的主執行緒之外執行，所以查詢時頁面不會卡住。這個 worker 由 `pagefind.js` 在第一次查詢時啟動：

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

接著 worker 會下載 WASM 並編譯它。**從 URL 載入的專用 worker 套用的是載入它當次回應上的 CSP**，而不是啟動它的那個頁面的政策。[MDN 直接寫明了這一點](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)。唯一的例外是從 `blob:` 或 `data:` URL 建立的 worker，這種 worker 會繼承建立者的政策。

*Figure — WorkerCsp: 每個回應都帶著同一份政策的副本。能不能編譯，由 `/pagefind/pagefind-worker.js` 回應上的那一份決定，而那一份裡沒有 `'wasm-unsafe-eval'`。*

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

```json title="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（跨站腳本攻擊）注入的內嵌腳本，看起來和本站自己的一模一樣。所以政策裡列了六個雜湊，各自對應本站自己的一段內嵌腳本：

```text title="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` 記錄下來的錯誤：

```text
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 的託管說明文件](https://pagefind.app/docs/hosting/)也指出，Pagefind 需要的就是 `'wasm-unsafe-eval'` 這個關鍵字。

> **千萬別這樣**
>
> **不要改用 'unsafe-eval'**
>
> `'unsafe-eval'` 也能讓 WebAssembly 編譯，但連 JavaScript 的 `eval()` 都會被允許。`eval()` 會把任何字串當成程式碼執行，一旦攻擊者能把字串送進 `eval()`，就能在頁面上執行任意腳本。CSP 擋下 `eval()`，就是為了防止這種情況。

我當初考慮了兩個可以放 `'wasm-unsafe-eval'` 的地方：

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

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

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

*Figure — TwoCspHeaders: 就算新增一條針對 `/pagefind/*` 的規則，也只會多加一份政策，原本全站規則的那一份政策還是會一起被套用。*

如果要讓 `/pagefind/*` 的規則行得通，就得在全站規則的 `source` 裡寫一個負向先行斷言（negative lookahead）的正規表示式，把 `/pagefind/` 排除在外。相比之下，在每個頁面都加上 `'wasm-unsafe-eval'` 不用大費周章，而且幾乎沒有任何代價，因為攻擊者得先有辦法在頁面上執行 JavaScript 去呼叫 `WebAssembly.instantiate()`。而能做到這一步的攻擊者，本來就能直接用 JavaScript 去做任何事，根本用不著特地使用 WASM。

> **話說**
>
> **把 /pagefind/ 從全站規則排除的寫法**
>
> 全站規則的 `source` 要改成 `/((?!pagefind/).*)`。這是一個負向先行斷言（negative lookahead）的正規表示式，意思是「符合所有不以 `/pagefind/` 開頭的路徑」。這樣一來，worker 就只會收到 `/pagefind/*` 規則的那一份政策。

這個部落格的 CSP 政策並不是手寫的，而是腳本自動產生出來的。`scripts/compute-csp-hashes.ts` 會為建置結果中的內嵌腳本計算雜湊，再把 header 寫進 `vercel.json`；只要 `vercel.json` 裡的 header 和建置結果算出的雜湊不一致，`pnpm check` 就會失敗。因此，我把 `'wasm-unsafe-eval'` 加進了產生政策的腳本：

```ts title="scripts/compute-csp-hashes.ts"
const scriptSrc = `script-src 'self' 'wasm-unsafe-eval' ${scriptHashes.map((h) => `'${h}'`).join(" ")}`;
// 修改前：script-src 'self' ${…}
```

> **話說**
>
> **為什麼不需要 worker-src？**
>
> [Pagefind 的說明文件](https://pagefind.app/docs/hosting/)也提到，限制了 worker 的網站可能需要 `worker-src 'self' blob:`。`worker-src` 決定 worker 腳本能從哪裡載入，`blob:` 則是指向頁面在記憶體裡自己產生的資料的 URL，從這種 URL 啟動的 worker 才需要允許它。這個部落格兩者都用不到。Pagefind 是直接從 `pagefind-worker.js` 這個檔案啟動 worker，不經過 `blob:`。而沒有 `worker-src` 時，瀏覽器會改用 `script-src`，裡面的 `'self'` 已經允許同源的 `pagefind-worker.js`。

## 為什麼部署完並重新載入之後，搜尋還是卡住？

部署之後，worker 的回應已經帶著新的政策。但在我的瀏覽器裡，就算重新整理頁面之後搜尋，搜尋結果還是卡在「Searching for claude…」無法搜尋。最後在 Chrome 裡點選「Empty Cache and Hard Reload」（清除快取並強制重新載入）才解決了問題。打開 DevTools 之後，對重新載入按鈕按右鍵就會出現這個選項。下面的截圖是 Chrome 的日文介面。

*Image: Chrome 的重新載入選單，列出三個項目：一般重新載入（⌘R）、強制重新載入（⇧⌘R），以及清除快取並強制重新載入*

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

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

worker 的回應帶著 `Cache-Control: public, max-age=0, must-revalidate`，意思是瀏覽器可以快取這個檔案，但每次使用前都必須先向伺服器確認它是否仍是最新的。確認的方式是把快取檔案的 ETag 放進 `If-None-Match` 送給伺服器。

> **話說**
>
> **ETag 與 304**
>
> ETag 是伺服器依檔案內容產生的識別碼，只要內容一變，ETag 就會跟著變。瀏覽器快取檔案時會一併記下 ETag，之後確認時再把它放進 `If-None-Match` 送回伺服器。檔案沒有變的話，伺服器只回一個不附內容的 304 Not Modified，瀏覽器繼續使用快取的檔案；檔案變了，伺服器才會回 200 和新的檔案。

修正只改了 header，檔案一個位元組都沒有變。因此 ETag 也沒有變，Vercel 回傳了 304 Not Modified。我用 `curl` 重現了這個請求：

```bash
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](https://www.rfc-editor.org/rfc/rfc9111#section-4.3.4) 規定，瀏覽器收到 304 時，要用 304 裡的 header 更新快取檔案原本存下的 header。所以只要 304 裡有 CSP header，Chromium 就會用它取代舊的政策。但是**這個 304 沒有帶 `Content-Security-Policy`，所以快取裡那份舊的政策沒有被換掉**。

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

*Figure — RevalidationCsp: 只改 header 並不會改變 ETag，而 304 也沒有帶來新的 CSP 政策。*

## 把政策的雜湊寫進 worker

我考慮了兩種解決方法：

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

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

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

```ts title="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, ""));
```

```js title="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 查詢會拿到新的檔案：

```bash
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'` 的政策，搜尋也就恢復正常了。

> **小心喔**
>
> **HTML 頁面可能也有同樣的問題**
>
> 這個部落格的 Service Worker 會把整個回應連同 header 存進頁面快取。只改 CSP 政策的話，回訪的讀者從快取打開的頁面，可能還是套用舊的政策。這一點我還沒有實際測量。

## 總結

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

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

## 參考連結

- [Pagefind: Hosting（包含 Content Security Policy 與 Web Worker 的設定）](https://pagefind.app/docs/hosting/)
- [Introducing Pagefind（CloudCannon 介紹分塊索引的發表文章）](https://cloudcannon.com/blog/introducing-pagefind/)
- [MDN: Content-Security-Policy（包含 worker 如何取得政策）](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)
- [Content Security Policy Level 3（定義 'wasm-unsafe-eval'）](https://www.w3.org/TR/CSP3/)
- [RFC 9111 第 4.3.4 節：驗證時更新已儲存的回應](https://www.rfc-editor.org/rfc/rfc9111#section-4.3.4)
