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

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

ほぼ黒のカードに白のpagefindのマークとロゴタイプ。マークは針を囲む楕円で、その横に小文字のロゴタイプが並ぶ
目次

はじめに

自分のブログで「claude」と検索したところ、検索ボックスが 「Searching for claude…」 と表示したまま、結果もエラーも出ませんでした。2 か月前にサイトへ Content-Security-Policy(CSP)を追加してからずっとこの状態で、その間に検索した読者は全員、同じ空のボックスを見ていたことになります。

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

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

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

ワーカーはその後 WASM を取得してコンパイルします。URL から読み込まれた専用ワーカーは、起動したページのポリシーではなく、自分のスクリプトと一緒に送られた CSP に従います。MDN にもはっきり書かれており、例外は 1 つだけです。blob: や data: の URL から作られたワーカーは、作成元のポリシーを引き継ぎます。

Pagefind のワーカーが Content-Security-Policy を受け取る場所source が /(.*) の vercel.json のルールが、script-src に wasm-unsafe-eval を含まない Content-Security-Policy を設定している。2 本の矢印が、この 1 つのポリシーを 2 つのコピーとして運ぶ。1 つはページのレスポンスに、もう 1 つはワーカー自身のレスポンスに付く。ページのカードでは pagefind.js が new Worker() でワーカーを起動する。ワーカーのカードでは pagefind-worker.js が、Rust を WASM にコンパイルした Pagefind のエンジンをダウンロードして WebAssembly.instantiate() を呼ぶが、ワーカーのコピーのポリシーに拒否される。vercel.jsonsource: "/(.*)" → script-src 'self' …✗ 'wasm-unsafe-eval' がないページのレスポンスに付くワーカー自身のレスポンスに付くページ:oharu121.com/Content-Security-Policyページのレスポンスに付いたコピーpagefind.jsページ上で動くワーカー:/pagefind/pagefind-worker.jsContent-Security-Policyワーカー自身のレスポンスに付いたコピーpagefind-worker.jsWASM を読み込んで実行する JSnew Worker()WASM をダウンロードWebAssembly.instantiate()Pagefind のエンジン:Rust を WASM にコンパイル✗ 拒否:'wasm-unsafe-eval' がない判定に使われるのはワーカーのコピーで、そこに 'wasm-unsafe-eval' はない
レスポンスごとに、同じポリシーのコピーが付きます。コンパイルの可否を決めるのは /pagefind/pagefind-worker.js に付いたコピーで、そこには 'wasm-unsafe-eval' がありません。

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

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 つに対応しています。

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

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

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

'wasm-unsafe-eval' は、ページやワーカーに WebAssembly のコンパイルとインスタンス化を許可するだけのキーワードです。Pagefind のホスティングに関するドキュメントも、Pagefind に必要なキーワードとして 'wasm-unsafe-eval' を挙げています。

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

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

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

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

2 つのルールと 2 つの Content-Security-Policy ヘッダー左の vercel.json には、script-src に wasm-unsafe-eval を含まない /(.*) のキャッチオールルールと、それを含む /pagefind/(.*) の狭いルールがある。どちらもワーカーのパスに一致するため、/pagefind/pagefind-worker.js のレスポンスには Content-Security-Policy ヘッダーが 2 つ付く。下では、キャッチオールのポリシーが 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/* 向けのルールを新しく足しても、ポリシーが 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' は生成スクリプトに追加しました。

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

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

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

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

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

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

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

HTTP キャッシュの仕様である RFC 9111 では、ブラウザーは 304 を受け取ると、その 304 のヘッダーでキャッシュ済みファイルのヘッダーを更新します。そのため 304 に CSP ヘッダーが付いていれば、Chromium はそれで古いポリシーを置き換えます。しかし この 304 には Content-Security-Policy が付いていないので、キャッシュにある古いポリシーは置き換えられません。

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

ヘッダーだけの変更が 304 で再検証される流れブラウザーのキャッシュと Vercel のやり取り。初回の訪問では、ブラウザーが /pagefind/pagefind-worker.js を要求し、ETag 8ff5a7a7 と wasm-unsafe-eval のない CSP が付いた 200 を受け取って、ファイルをそれらのヘッダーと一緒に保存する。その後のデプロイで CSP ヘッダーは変わるがファイルは変わらないため、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ブラウザーはファイルとこれらのヘッダーを保存するデプロイ:CSP ヘッダーは変わるがファイルは変わらないので、ETag は 8ff5a7a7… のまま次の読み込みIf-None-Match: 8ff5a7a7…304 · cache-control と etag だけで Content-Security-Policy はない✗ 保存された古い CSP が有効なままなので、コンパイルは拒否され続ける
ヘッダーだけを変えても ETag は変わらず、304 には保存済みのポリシーを置き換えるポリシーが付いていません。

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

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

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

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

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

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

このサイトには、オフライン用のキャッシュを担当する 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 200
etag: "475b8920f22a66ecfaf009a83edafc7e"

ファイルの内容が変わったので古い ETag はもう一致せず、サーバーは 304 ではなく、新しいファイルと新しい CSP ヘッダーが付いた 200 を返します。古いワーカーをキャッシュしていたブラウザーも、次に通常どおり読み込んだときに、キャッシュを新しいファイルと 'wasm-unsafe-eval' を含むポリシーに置き換え、検索が元どおり使えるようになります。

まとめ

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

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

参考リンク

この記事をシェア