# Pagefind の検索が CSP で止まった — ワーカーは自分のスクリプトに付いた CSP に従い、304 にはその CSP が付かない

> Vercel 上の Pagefind 検索が、CSP に wasm-unsafe-eval がないために止まっていました。修正後も、ワーカーをキャッシュしていたブラウザーは 304 に CSP が付かないため古いポリシーを使い続け、ポリシーのハッシュを埋め込んで解決しました。

- Source: https://oharu121.com/ja/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…」** と表示したまま、結果もエラーも出ませんでした。2 か月前にサイトへ [Content-Security-Policy（CSP）を追加して](/ja/blog/astro-vercel-security-csp-service-worker-hash/)からずっとこの状態で、その間に検索した読者は全員、同じ空のボックスを見ていたことになります。

CSP の `script-src` に `'wasm-unsafe-eval'` がなかったため、ブラウザーは Pagefind がワーカーの中で実行する WebAssembly のコンパイルを拒否していました。エラーは DevTools のコンソールにしか出ず、検索はいつまでも結果を待ち続けていました。

*Image: 検索ボックスに「Searching for claude…」と表示され、DevTools のコンソールには「Failed to load the Pagefind WASM」と Content Security Policy に関する CompileError を含むエラーが 4 件並んでいる*

*ページは検索中のように見えます。WASM が拒否されたことを示しているのは、赤く表示されたコンソールだけです。*

`'wasm-unsafe-eval'` を追加して直ったのは、新しく訪れた読者だけでした。ワーカーをキャッシュしたブラウザーを使っている再訪問者の検索は、止まったままでした。ブラウザーはキャッシュしたワーカーが最新かどうかを Vercel に確認しますが、**「変更なし」を意味する 304 の応答には CSP ヘッダーが付いていません**。そのためブラウザーは、キャッシュにある古いポリシーを使い続けます。

今はポリシーのハッシュをワーカーのファイルに埋め込んでいるので、ポリシーを変えれば、どのブラウザーにも次の読み込みで届きます。

## Pagefind が検索を実行する仕組み

Pagefind は静的サイト向けの検索ライブラリです。ビルド時に生成された HTML からインデックスを作り、小さな JavaScript と WebAssembly（WASM）のランタイムと一緒に配信します。

> **ちなみに**
>
> **Pagefind が WebAssembly を使う理由**
>
> 通信量を抑えるため、Pagefind はインデックスを圧縮済みのバイナリの小さなチャンクに分け、クエリに必要なチャンクだけをダウンロードします（[CloudCannon の発表記事](https://cloudcannon.com/blog/introducing-pagefind/)）。そのチャンクを読むエンジンは Rust で書かれ、WebAssembly（WASM）にコンパイルされています。WASM は、ブラウザーが JavaScript と並べて実行できるバイナリ形式です。

Pagefind 1.5 では、検索はページのメインスレッドの外にある専用ワーカーの中で実行されるので、検索中も入力やスクロールが止まりません。このワーカーは、`pagefind.js` が最初のクエリで起動します。

```js title="pagefind.js（Pagefind 1.5.2、抜粋、minify 済みのコードを整形）"
workerUrl = `${basePath}pagefind-worker.js`;
sharedWorker = new Worker(workerUrl);
```

ワーカーはその後 WASM を取得してコンパイルします。**URL から読み込まれた専用ワーカーは、起動したページのポリシーではなく、自分のスクリプトと一緒に送られた CSP に従います**。[MDN にもはっきり書かれており](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)、例外は 1 つだけです。`blob:` や `data:` の URL から作られたワーカーは、作成元のポリシーを引き継ぎます。

*Figure — WorkerCsp: レスポンスごとに、同じポリシーのコピーが付きます。コンパイルの可否を決めるのは `/pagefind/pagefind-worker.js` に付いたコピーで、そこには `'wasm-unsafe-eval'` がありません。*

つまり、検索が動くかどうかを決めるのは `/pagefind/pagefind-worker.js` のヘッダーです。このサイトの `vercel.json` には、`headers` の中に `source` が `/(.*)` のルールが 1 つあります（以下、キャッチオールルール）。`/(.*)` はすべてのパスに一致するので、全ページとワーカーには、このルールで設定した同じ CSP ヘッダーが付きます。

```json title="vercel.json（抜粋）"
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; … script-src 'self' …"
        }
      ]
    }
  ]
}
```

このポリシーには `'wasm-unsafe-eval'` がないので、ワーカーもページと同じく WASM のコンパイルを許可されていません。直すべきはこのキャッチオールルールです。

## CSP はなぜ WASM のコンパイルをブロックするのか？

問題は CSP の `script-src` でした。許可しているスクリプトは 2 種類だけです。

1. `'self'`：このサイトのドメインから読み込むスクリプトファイルだけを許可し、ほかのドメインのサードパーティスクリプトはすべてブロックします。
2. ハッシュ：HTML に直接書いたインラインスクリプトはデフォルトですべてブロックし、内容が特定のハッシュに一致するものだけを許可します。

インラインスクリプトをデフォルトでブロックするのは、XSS（クロスサイトスクリプティング）で注入されたインラインスクリプトが、このサイト自身のものと見分けがつかないからです。そのためポリシーには 6 つのハッシュが並び、それぞれがこのサイト自身のインラインスクリプト 1 つに対応しています。

```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=' …".
```

このエラーはワーカーの外には伝わりません。**検索ボックスが待っている Promise はいつまでも解決しない** ので、ボックスは「Searching for claude…」のままで、ページ上には失敗を示すものが何も出ません。

## `'wasm-unsafe-eval'` を置く場所

**`'wasm-unsafe-eval'` は、ページやワーカーに WebAssembly のコンパイルとインスタンス化を許可するだけのキーワードです**。[Pagefind のホスティングに関するドキュメント](https://pagefind.app/docs/hosting/)も、Pagefind に必要なキーワードとして `'wasm-unsafe-eval'` を挙げています。

> **絶対ダメ**
>
> **'unsafe-eval' で代用しない**
>
> `'unsafe-eval'` でも WebAssembly はコンパイルできますが、JavaScript の `eval()` まで許可されます。`eval()` は任意の文字列をコードとして実行するので、攻撃者が文字列を渡せればページ上で任意のスクリプトを実行できてしまいます。CSP で `eval()` を禁止するのは、これを防ぐためです。

`'wasm-unsafe-eval'` の置き場所として、2 つを検討しました。

1. キャッチオールルール：ページも Pagefind のファイルも含め、すべてのパスに適用されます。
2. `/pagefind/*` のルール：パスが `/pagefind/` で始まるファイル、つまり Pagefind 自身のファイルにだけ適用されます。

私はキャッチオールルールを選びました。**`/pagefind/*` のルールを足すだけでは、ワーカーはキャッチオールのポリシーにブロックされたまま** だからです。

`/pagefind/*` のルールを追加すると、ワーカーは両方のルールに一致するので、`Content-Security-Policy` ヘッダーを 2 つ受け取ります。[Vercel で Pagefind を使っている別のサイトでも同じことが起きています](https://github.com/useAvalon/Avalon/pull/125)。**ブラウザーは受け取ったポリシーをすべて同時に適用し**、コンパイルはそのすべてを通る必要があります。`/pagefind/*` のポリシーはコンパイルを許可しますが、キャッチオールのポリシーは拒否したままです。

*Figure — TwoCspHeaders: `/pagefind/*` 向けのルールを新しく足しても、ポリシーが 1 つ増えるだけで、元のキャッチオールルールのポリシーも一緒に適用されます。*

`/pagefind/*` のルールを機能させるには、キャッチオールルールの `source` に否定先読み（negative lookahead）の正規表現を書き、`/pagefind/` を除外する必要があります。それに比べて、全ページに `'wasm-unsafe-eval'` を付けるのは手間もかからず、失うものもほとんどありません。これを悪用するには、攻撃者はまずページ上で JavaScript を実行して `WebAssembly.instantiate()` を呼べなければならないからです。そこまでできる攻撃者は JavaScript で何でもできるので、わざわざ WASM を使う必要がありません。

> **ちなみに**
>
> **キャッチオールルールから /pagefind/ を除外する書き方**
>
> キャッチオールルールの `source` を `/((?!pagefind/).*)` に変えます。これは否定先読み（negative lookahead）の正規表現で、「`/pagefind/` で始まらないすべてのパスに一致する」という意味です。こうすれば、ワーカーには `/pagefind/*` のルールのポリシーだけが付きます。

このサイトのポリシーは手書きではなく生成しています。`scripts/compute-csp-hashes.ts` がビルド内のインラインスクリプトをハッシュ化して `vercel.json` にヘッダーを書き込み、両者が食い違えば `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-src 'self' blob:` も載っています。`worker-src` はワーカーのスクリプトをどこから読み込めるかを決め、`blob:` はページがメモリ上で自分で作ったデータを指す URL です。そうした URL から起動するワーカーだけが、`blob:` の許可を必要とします。このサイトにはどちらも不要です。Pagefind は `pagefind-worker.js` というファイルから直接ワーカーを起動し、`blob:` は使いません。また `worker-src` がなければブラウザーは `script-src` を使い、その `'self'` が同一オリジンの `pagefind-worker.js` をすでに許可しています。

## デプロイして再読み込みしても検索が止まったままだった理由

デプロイ後、ワーカーのレスポンスには新しいポリシーが付いていました。それでも私のブラウザーでは、通常の再読み込みをしても検索ボックスは「Searching for claude…」のままでした。直ったのは Chrome の「キャッシュの消去とハード再読み込み」を使ったときだけです。このメニューは、DevTools を開いた状態で再読み込みボタンを右クリックすると表示されます。

*Image: Chrome の再読み込みメニューに、通常の再読み込み（⌘R）、ハード再読み込み（⇧⌘R）、キャッシュの消去とハード再読み込みの 3 つの項目が並んでいる*

*Image: 検索ボックスに「clau」と入力され、抜粋中の「Claude」が強調された記事の検索結果が並んでいる*

*「キャッシュの消去とハード再読み込み」の後は、同じブラウザーで検索結果が返ってきます。*

ワーカーは `Cache-Control: public, max-age=0, must-revalidate` 付きで配信されています。ブラウザーはこのファイルをキャッシュできますが、使う前には毎回、まだ最新かどうかをサーバーに確認しなければなりません。確認には、キャッシュしたファイルの ETag を `If-None-Match` に入れて送ります。

> **ちなみに**
>
> **ETag と 304**
>
> ETag は、サーバーがファイルの内容から作る識別子で、内容が変われば ETag も変わります。ブラウザーはファイルをキャッシュするときに ETag も記録し、確認のときにそれを `If-None-Match` に入れてサーバーへ送ります。ファイルが変わっていなければ、サーバーは本文のない 304 Not Modified を返し、ブラウザーはキャッシュしたファイルを使い続けます。変わっていれば、サーバーは 200 と新しいファイルを返します。

修正で変わったのはヘッダーだけで、ファイルは 1 バイトも変わっていません。そのため 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 のヘッダーでキャッシュ済みファイルのヘッダーを更新します。そのため 304 に CSP ヘッダーが付いていれば、Chromium はそれで古いポリシーを置き換えます。しかし **この 304 には `Content-Security-Policy` が付いていないので、キャッシュにある古いポリシーは置き換えられません**。

ブラウザーは最初に保存したポリシーを、**キャッシュしたファイルが破棄されるか、読者がハード再読み込みをするまで**使い続けます。

*Figure — RevalidationCsp: ヘッダーだけを変えても ETag は変わらず、304 には保存済みのポリシーを置き換えるポリシーが付いていません。*

## ポリシーのハッシュをワーカーに埋め込む

修正方法として、2 つを検討しました。

1. `/pagefind/*` に `Cache-Control: no-store` を付けて、ブラウザーに Pagefind のファイルをキャッシュさせない。読み込みのたびにヘッダー付きの 200 を丸ごと取得できますが、その代わり、検索を使うページを開くたびにバンドルをダウンロードし直すことになります。
2. ポリシーが変わるたびにワーカーのファイルのバイト列を変え、ETag も一緒に変える。ポリシーが変わらない限り、コストはかかりません。

私はバイト列を変える方法を選びました。

**今は、Pagefind がワーカーを出力した後に、ビルドの 1 ステップとしてポリシーのハッシュをワーカー先頭のコメントに書き込んでいます**。ハッシュは、ポリシーの SHA-256 を 16 進数で表した先頭 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";…
```

**ポリシーが変わらなければ同じコメントが書き込まれる** ので、ファイルはバイト単位で同一のままで、ブラウザーが再ダウンロードすることもありません。

このサイトには、オフライン用のキャッシュを担当する Service Worker（`/sw.js`）というもう 1 つのワーカーがあり、同じようにポリシーが更新されない問題が起きます。そこで同じハッシュを `/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 ヘッダーが付いた 200 を返します。古いワーカーをキャッシュしていたブラウザーも、次に通常どおり読み込んだときに、キャッシュを新しいファイルと `'wasm-unsafe-eval'` を含むポリシーに置き換え、検索が元どおり使えるようになります。

> **気を付けて**
>
> **HTML のページにも同じ問題があるかもしれない**
>
> このサイトの Service Worker は、ページキャッシュにレスポンスをヘッダーごと保存しています。CSP だけを変えた場合、再訪問者がキャッシュから開いたページは、古いポリシーのまま動く可能性があります。これはまだ計測していません。

## まとめ

このサイトの検索は、新しい訪問者にも再訪問者にも再び使えるようになりました。今回の経緯を振り返ると、次のようになります。

1. Pagefind は WASM をワーカーの中でコンパイルし、ワーカーは自分が読み込まれたときのレスポンスに付いた CSP に従います。そのため `'wasm-unsafe-eval'` はキャッチオールルールに入れる必要がありました。`/pagefind/*` のルールを別に足してもポリシーが 1 つ増えるだけで、コンパイルをブロックするキャッチオールのポリシーは一緒に適用されたままです。
2. ページを読み込むだけの CSP チェックでは、このエラーは見つかりません。WASM は最初に検索したときにはじめてコンパイルされるからです。検索が使えることを確かめるには、実際に検索するしかありません。
3. Vercel では、ヘッダーだけを変えても、ワーカーをキャッシュしたブラウザーには届きません。再検証で受け取る 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（ワーカーがポリシーを受け取る仕組みを含む）](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)
