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

目次
はじめに
自分のブログで「claude」と検索したところ、検索ボックスが 「Searching for claude…」 と表示したまま、結果もエラーも出ませんでした。2 か月前にサイトへ Content-Security-Policy(CSP)を追加してからずっとこの状態で、その間に検索した読者は全員、同じ空のボックスを見ていたことになります。
CSP の script-src に 'wasm-unsafe-eval' がなかったため、ブラウザーは Pagefind がワーカーの中で実行する WebAssembly のコンパイルを拒否していました。エラーは DevTools のコンソールにしか出ず、検索はいつまでも結果を待ち続けていました。

'wasm-unsafe-eval' を追加して直ったのは、新しく訪れた読者だけでした。ワーカーをキャッシュしたブラウザーを使っている再訪問者の検索は、止まったままでした。ブラウザーはキャッシュしたワーカーが最新かどうかを Vercel に確認しますが、「変更なし」を意味する 304 の応答には CSP ヘッダーが付いていません。そのためブラウザーは、キャッシュにある古いポリシーを使い続けます。
今はポリシーのハッシュをワーカーのファイルに埋め込んでいるので、ポリシーを変えれば、どのブラウザーにも次の読み込みで届きます。
Pagefind が検索を実行する仕組み
Pagefind は静的サイト向けの検索ライブラリです。ビルド時に生成された HTML からインデックスを作り、小さな JavaScript と WebAssembly(WASM)のランタイムと一緒に配信します。
Pagefind 1.5 では、検索はページのメインスレッドの外にある専用ワーカーの中で実行されるので、検索中も入力やスクロールが止まりません。このワーカーは、pagefind.js が最初のクエリで起動します。
workerUrl = `${basePath}pagefind-worker.js`;sharedWorker = new Worker(workerUrl);ワーカーはその後 WASM を取得してコンパイルします。URL から読み込まれた専用ワーカーは、起動したページのポリシーではなく、自分のスクリプトと一緒に送られた CSP に従います。MDN にもはっきり書かれており、例外は 1 つだけです。blob: や data: の URL から作られたワーカーは、作成元のポリシーを引き継ぎます。
/pagefind/pagefind-worker.js に付いたコピーで、そこには 'wasm-unsafe-eval' がありません。つまり、検索が動くかどうかを決めるのは /pagefind/pagefind-worker.js のヘッダーです。このサイトの vercel.json には、headers の中に source が /(.*) のルールが 1 つあります(以下、キャッチオールルール)。/(.*) はすべてのパスに一致するので、全ページとワーカーには、このルールで設定した同じ CSP ヘッダーが付きます。
{ "headers": [ { "source": "/(.*)", "headers": [ { "key": "Content-Security-Policy", "value": "default-src 'self'; … script-src 'self' …" } ] } ]}このポリシーには 'wasm-unsafe-eval' がないので、ワーカーもページと同じく WASM のコンパイルを許可されていません。直すべきはこのキャッチオールルールです。
CSP はなぜ WASM のコンパイルをブロックするのか?
問題は CSP の script-src でした。許可しているスクリプトは 2 種類だけです。
'self':このサイトのドメインから読み込むスクリプトファイルだけを許可し、ほかのドメインのサードパーティスクリプトはすべてブロックします。- ハッシュ:HTML に直接書いたインラインスクリプトはデフォルトですべてブロックし、内容が特定のハッシュに一致するものだけを許可します。
インラインスクリプトをデフォルトでブロックするのは、XSS(クロスサイトスクリプティング)で注入されたインラインスクリプトが、このサイト自身のものと見分けがつかないからです。そのためポリシーには 6 つのハッシュが並び、それぞれがこのサイト自身のインラインスクリプト 1 つに対応しています。
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=' …".このエラーはワーカーの外には伝わりません。検索ボックスが待っている Promise はいつまでも解決しない ので、ボックスは「Searching for claude…」のままで、ページ上には失敗を示すものが何も出ません。
'wasm-unsafe-eval' を置く場所
'wasm-unsafe-eval' は、ページやワーカーに WebAssembly のコンパイルとインスタンス化を許可するだけのキーワードです。Pagefind のホスティングに関するドキュメントも、Pagefind に必要なキーワードとして 'wasm-unsafe-eval' を挙げています。
'wasm-unsafe-eval' の置き場所として、2 つを検討しました。
- キャッチオールルール:ページも Pagefind のファイルも含め、すべてのパスに適用されます。
/pagefind/*のルール:パスが/pagefind/で始まるファイル、つまり Pagefind 自身のファイルにだけ適用されます。
私はキャッチオールルールを選びました。/pagefind/* のルールを足すだけでは、ワーカーはキャッチオールのポリシーにブロックされたまま だからです。
/pagefind/* のルールを追加すると、ワーカーは両方のルールに一致するので、Content-Security-Policy ヘッダーを 2 つ受け取ります。Vercel で Pagefind を使っている別のサイトでも同じことが起きています (useAvalon/Avalon#125)。ブラウザーは受け取ったポリシーをすべて同時に適用し、コンパイルはそのすべてを通る必要があります。/pagefind/* のポリシーはコンパイルを許可しますが、キャッチオールのポリシーは拒否したままです。
/pagefind/* 向けのルールを新しく足しても、ポリシーが 1 つ増えるだけで、元のキャッチオールルールのポリシーも一緒に適用されます。/pagefind/* のルールを機能させるには、キャッチオールルールの source に否定先読み(negative lookahead)の正規表現を書き、/pagefind/ を除外する必要があります。それに比べて、全ページに 'wasm-unsafe-eval' を付けるのは手間もかからず、失うものもほとんどありません。これを悪用するには、攻撃者はまずページ上で JavaScript を実行して WebAssembly.instantiate() を呼べなければならないからです。そこまでできる攻撃者は JavaScript で何でもできるので、わざわざ WASM を使う必要がありません。
このサイトのポリシーは手書きではなく生成しています。scripts/compute-csp-hashes.ts がビルド内のインラインスクリプトをハッシュ化して vercel.json にヘッダーを書き込み、両者が食い違えば pnpm check が失敗します。そのため、'wasm-unsafe-eval' は生成スクリプトに追加しました。
const scriptSrc = `script-src 'self' 'wasm-unsafe-eval' ${scriptHashes.map((h) => `'${h}'`).join(" ")}`;// 変更前: script-src 'self' ${…}デプロイして再読み込みしても検索が止まったままだった理由
デプロイ後、ワーカーのレスポンスには新しいポリシーが付いていました。それでも私のブラウザーでは、通常の再読み込みをしても検索ボックスは「Searching for claude…」のままでした。直ったのは Chrome の「キャッシュの消去とハード再読み込み」を使ったときだけです。このメニューは、DevTools を開いた状態で再読み込みボタンを右クリックすると表示されます。


ワーカーは Cache-Control: public, max-age=0, must-revalidate 付きで配信されています。ブラウザーはこのファイルをキャッシュできますが、使う前には毎回、まだ最新かどうかをサーバーに確認しなければなりません。確認には、キャッシュしたファイルの ETag を If-None-Match に入れて送ります。
修正で変わったのはヘッダーだけで、ファイルは 1 バイトも変わっていません。そのため 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 のヘッダーでキャッシュ済みファイルのヘッダーを更新します。そのため 304 に CSP ヘッダーが付いていれば、Chromium はそれで古いポリシーを置き換えます。しかし この 304 には Content-Security-Policy が付いていないので、キャッシュにある古いポリシーは置き換えられません。
ブラウザーは最初に保存したポリシーを、キャッシュしたファイルが破棄されるか、読者がハード再読み込みをするまで使い続けます。
ポリシーのハッシュをワーカーに埋め込む
修正方法として、2 つを検討しました。
/pagefind/*にCache-Control: no-storeを付けて、ブラウザーに Pagefind のファイルをキャッシュさせない。読み込みのたびにヘッダー付きの 200 を丸ごと取得できますが、その代わり、検索を使うページを開くたびにバンドルをダウンロードし直すことになります。- ポリシーが変わるたびにワーカーのファイルのバイト列を変え、ETag も一緒に変える。ポリシーが変わらない限り、コストはかかりません。
私はバイト列を変える方法を選びました。
今は、Pagefind がワーカーを出力した後に、ビルドの 1 ステップとしてポリシーのハッシュをワーカー先頭のコメントに書き込んでいます。ハッシュは、ポリシーの SHA-256 を 16 進数で表した先頭 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";…ポリシーが変わらなければ同じコメントが書き込まれる ので、ファイルはバイト単位で同一のままで、ブラウザーが再ダウンロードすることもありません。
このサイトには、オフライン用のキャッシュを担当する Service Worker(/sw.js)というもう 1 つのワーカーがあり、同じようにポリシーが更新されない問題が起きます。そこで同じハッシュを /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 ヘッダーが付いた 200 を返します。古いワーカーをキャッシュしていたブラウザーも、次に通常どおり読み込んだときに、キャッシュを新しいファイルと 'wasm-unsafe-eval' を含むポリシーに置き換え、検索が元どおり使えるようになります。
まとめ
このサイトの検索は、新しい訪問者にも再訪問者にも再び使えるようになりました。今回の経緯を振り返ると、次のようになります。
- Pagefind は WASM をワーカーの中でコンパイルし、ワーカーは自分が読み込まれたときのレスポンスに付いた CSP に従います。そのため
'wasm-unsafe-eval'はキャッチオールルールに入れる必要がありました。/pagefind/*のルールを別に足してもポリシーが 1 つ増えるだけで、コンパイルをブロックするキャッチオールのポリシーは一緒に適用されたままです。 - ページを読み込むだけの CSP チェックでは、このエラーは見つかりません。WASM は最初に検索したときにはじめてコンパイルされるからです。検索が使えることを確かめるには、実際に検索するしかありません。
- Vercel では、ヘッダーだけを変えても、ワーカーをキャッシュしたブラウザーには届きません。再検証で受け取る 304 に CSP が付かないからです。ポリシーのハッシュをファイルに埋め込み、ポリシーと一緒にファイルも変わるようにして、はじめてすべてのブラウザーに新しいポリシーが届きます。



