Pagefind search hung under a strict CSP — the worker reads its own header, and a 304 drops it

Pagefind search on Vercel hung because the CSP lacked wasm-unsafe-eval. After the fix, cached workers kept the old policy, because Vercel's 304 carries no CSP.

The pagefind compass mark, an ellipse enclosing a needle, beside the lowercase pagefind wordmark in white on a near-black card
On this page

Introduction

I searched my own blog for “claude”, and the search box sat on “Searching for claude…” with no results and no error. It had been that way since I added a Content-Security-Policy (CSP) to the site two months earlier, so every reader who tried search in that time got the same empty box.

The policy’s script-src had no 'wasm-unsafe-eval', so the browser refused to compile the WebAssembly that Pagefind runs in a worker. The error appeared only in the DevTools console, and the search waited for a result that never came.

The search box shows "Searching for claude…" while the DevTools console lists four errors, each reading "Failed to load the Pagefind WASM" with a CompileError about the Content Security Policy

The page shows a search in progress. Only the console, in red, says the WASM was refused.

Adding 'wasm-unsafe-eval' let new visitors search again. Returning visitors whose browsers had cached the worker still hung. Their browser asked Vercel whether the cached worker was still current, and Vercel’s “not modified” answer, a 304, carries no CSP header, so the browser kept the old policy from its cache.

A hash of the policy is now stamped into the worker file, so any policy change reaches every browser on its next load.

Pagefind is a static search library. It indexes the built HTML at build time and ships that index with a small JavaScript and WebAssembly (WASM) runtime.

In Pagefind 1.5, a search runs in a dedicated worker, off the page’s main thread, so typing and scrolling stay responsive while it searches. pagefind.js starts that worker on the first query:

pagefind.js (Pagefind 1.5.2, excerpt, reformatted from the minified file)
workerUrl = `${basePath}pagefind-worker.js`;
sharedWorker = new Worker(workerUrl);

The worker then fetches the WASM and compiles it. A dedicated worker loaded from a URL runs under the CSP sent with its own script, not under the policy of the page that started it. MDN says so directly. The one exception is a worker created from a blob: or data: URL, which inherits its creator’s policy.

Where Pagefind's worker gets its Content-Security-PolicyA vercel.json rule with source /(.*) sets a Content-Security-Policy whose script-src has no 'wasm-unsafe-eval'. Two arrows carry that one policy down as two copies: one on the page's response, one on the worker's own response. In the page card, pagefind.js starts the worker with new Worker(). In the worker card, pagefind-worker.js downloads Pagefind's engine, Rust compiled to WASM, and calls WebAssembly.instantiate(), which the worker's copy of the policy refuses.vercel.jsonsource: "/(.*)" → script-src 'self' …✗ no 'wasm-unsafe-eval'on the page's responseon the worker's own responsePage: oharu121.com/Content-Security-Policycopy on the page's responsepagefind.jsruns on the pageWorker: /pagefind/pagefind-worker.jsContent-Security-Policycopy on the worker's own responsepagefind-worker.jsJS that loads and runs the WASMnew Worker()downloads the WASMWebAssembly.instantiate()Pagefind's engine: Rust, compiled to WASM✗ refused: no 'wasm-unsafe-eval'The compile is judged by the worker's copy, which has no 'wasm-unsafe-eval'
Each response carries its own copy of the same policy. The compile is judged by the copy on /pagefind/pagefind-worker.js, which has no 'wasm-unsafe-eval'.

So the header that decides whether search works is the one on /pagefind/pagefind-worker.js. On this site, vercel.json has one header rule whose source is /(.*), the catch-all rule. /(.*) matches every path, so every page and the worker get the same CSP header from it:

vercel.json (excerpt)
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; … script-src 'self' …"
}
]
}
]
}

That policy has no 'wasm-unsafe-eval', so the worker gets the same refusal as every page, and the catch-all rule is where the fix belongs.

Why does the CSP block compiling the WASM?

The problem was the CSP’s script-src. It allowed only two kinds of script:

  1. 'self': script files from the site’s own domain. Third-party scripts from any other domain are blocked.
  2. Hashes: inline scripts written in the HTML are blocked by default, except one whose content matches a listed hash.

Inline scripts are blocked by default because one injected through XSS (cross-site scripting) looks exactly like the site’s own. So the policy lists six hashes, each matching one of the site’s own inline scripts:

Content-Security-Policy (excerpt, before)
script-src 'self' 'sha256-FSnQm9awVo7jad+j9vKPRI9jNpEpWw3TfPfEjTiRH00=' …

script-src also governs code produced at runtime, such as a string passed to eval() or the bytes WebAssembly compiles. That code cannot be checked in advance the way a script file or a hash can, so a CSP blocks it by default. Without 'unsafe-eval' or the narrower 'wasm-unsafe-eval', WebAssembly.instantiate() throws.

When I opened DevTools on the stuck search box, it showed exactly that, logged from 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=' …".

The error stays inside the worker. The promise the search box waits on never settles, so it keeps showing “Searching for claude…” and nothing on the page says that anything failed.

Where 'wasm-unsafe-eval' goes

'wasm-unsafe-eval' lets a page or worker compile and instantiate WebAssembly, and does nothing else. Pagefind’s hosting documentation names 'wasm-unsafe-eval' as the keyword Pagefind needs.

I considered two places for 'wasm-unsafe-eval':

  1. The catch-all rule, which applies to every path, pages and Pagefind’s files alike.
  2. A /pagefind/* rule, which applies only to paths starting with /pagefind/, that is, Pagefind’s own files.

I chose the catch-all rule, because adding a /pagefind/* rule alone still leaves the worker blocked by the catch-all policy.

With a /pagefind/* rule added, the worker matches both rules and receives two Content-Security-Policy headers, as another Pagefind site on Vercel found (useAvalon/Avalon#125). A browser enforces every policy it is given, all at once, and a compile has to pass every one of them. The /pagefind/* policy allows it; the catch-all policy still refuses it.

Two rules, two Content-Security-Policy headersLeft, vercel.json has a catch-all rule for /(.*) whose script-src lacks 'wasm-unsafe-eval', and a narrower rule for /pagefind/(.*) whose script-src includes it. Both rules match the worker's path, so the response for /pagefind/pagefind-worker.js carries two Content-Security-Policy headers. Underneath, the catch-all policy refuses WebAssembly.instantiate() while the /pagefind/ policy allows it, and because the browser enforces both, the compile is still blocked.vercel.jsonResponse for /pagefind/pagefind-worker.jssource: "/(.*)"script-src 'self' …matchesContent-Security-Policyscript-src 'self' …source: "/pagefind/(.*)"script-src 'self' 'wasm-unsafe-eval'matchesContent-Security-Policyscript-src 'self' 'wasm-unsafe-eval'✗ the catch-all policy refuses WebAssembly.instantiate()✓ the /pagefind/ policy allows itThe browser enforces both, so the compile is still blocked
A new /pagefind/* rule only adds a policy. The catch-all rule’s policy is still applied alongside it.

Making a /pagefind/* rule work would mean writing a negative-lookahead regex into the catch-all rule’s source, so that it skips /pagefind/.

Putting 'wasm-unsafe-eval' on every page is far less work and costs almost nothing, because an attacker would first have to run JavaScript on the page to call WebAssembly.instantiate(). An attacker who can do that can already do anything with JavaScript and has no need for WASM.

The policy here is generated rather than written by hand. scripts/compute-csp-hashes.ts hashes the inline scripts in the build and writes the header into vercel.json, and pnpm check fails when the two disagree. So I added 'wasm-unsafe-eval' to the generator:

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

Why search still hung after the deploy and a reload

After the deploy, the worker’s response carried the new policy. In my browser, a plain reload still left the box on “Searching for claude…”. Only Chrome’s “Empty Cache and Hard Reload” cleared it. Chrome offers it when you right-click the reload button with DevTools open, shown here in Chrome’s Japanese UI as キャッシュの消去とハード再読み込み.

Chrome's reload menu with three entries: 通常の再読み込み (normal reload, ⌘R), ハード再読み込み (hard reload, ⇧⌘R) and キャッシュの消去とハード再読み込み (empty cache and hard reload)

The search box with "clau" typed in, listing article results with "Claude" highlighted in their excerpts

After “Empty Cache and Hard Reload”, the same browser returns results.

The worker is served with Cache-Control: public, max-age=0, must-revalidate, which lets a browser cache the file but makes it check with the server before every use. It checks by sending the cached file’s ETag in If-None-Match.

The fix changed a header and not a byte of the file, so the ETag stayed the same and Vercel answered 304 Not Modified. I replayed that request with curl:

Terminal window
IN
curl -sI -H 'If-None-Match: "8ff5a7a776be59b860655a35c70c008d"' https://oharu121.com/pagefind/pagefind-worker.js | grep -iE "^HTTP|content-security|cache-control|etag"
OUT
HTTP/2 304
cache-control: public, max-age=0, must-revalidate
etag: "8ff5a7a776be59b860655a35c70c008d"

Under RFC 9111, the HTTP caching spec, a browser that receives a 304 updates the cached file’s stored headers from it, so Chromium would replace the old policy with any CSP header the 304 carried. But the 304 carries no Content-Security-Policy, so the old policy in the cache is never replaced.

The browser keeps the policy it first stored until the cached file is evicted or the reader hard-reloads.

A header-only change revalidated with a 304A sequence between the browser cache and Vercel. On the first visit, the browser requests /pagefind/pagefind-worker.js and receives a 200 with ETag 8ff5a7a7 and a CSP without 'wasm-unsafe-eval', and stores the file with those headers. A deploy then changes the CSP header but not the file, so the ETag stays the same. On the next load the browser sends If-None-Match 8ff5a7a7, Vercel answers 304 with only cache-control and etag and no Content-Security-Policy, and the stored old policy stays in force, so the compile is still refused.Browser cacheVercelFirst visitGET /pagefind/pagefind-worker.js200 · ETag 8ff5a7a7… · CSP without 'wasm-unsafe-eval'The browser stores the file and these headersDeploy: the CSP header changes, the file does not, so the ETag stays 8ff5a7a7…Next loadIf-None-Match: 8ff5a7a7…304 · cache-control, etag · no Content-Security-Policy✗ The stored, old CSP stays in force, so the compile is still refused
A header-only change leaves the ETag alone, and the 304 brings no policy to replace the stored one.

Stamping the policy into the worker

I considered two fixes:

  1. Cache-Control: no-store on /pagefind/*, which forbids the browser from caching Pagefind’s files. Every load then gets a full 200 with its headers, at the cost of re-downloading the bundle on every page where someone searches.
  2. Change the worker file’s bytes whenever the policy changes, so its ETag changes with them. As long as the policy stays the same, this costs nothing.

I chose changing the bytes.

A build step now writes a hash of the policy into a comment at the top of the worker, after Pagefind has emitted it. The hash is the first 16 hex characters of the policy’s SHA-256:

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 (first lines, as deployed)
/* csp:826a419f579366b8 */
const pagefind_version="1.5.2";…

An unchanged policy produces the same comment, so the file stays byte-identical and no browser downloads it again.

The site has one more worker, the service worker at /sw.js that handles offline caching, and it would hit the same stale-policy problem. So I put the same hash into the version of /sw.js too, and any CSP change now makes browsers download /sw.js again.

After the deploy, the old ETag gets the new file:

Terminal window
IN
curl -sI -H 'If-None-Match: "8ff5a7a776be59b860655a35c70c008d"' https://oharu121.com/pagefind/pagefind-worker.js | grep -iE "^HTTP|etag"
OUT
HTTP/2 200
etag: "475b8920f22a66ecfaf009a83edafc7e"

The file’s content changed, so the old ETag no longer matches, and the server answers with a 200 carrying the new file and the new CSP header instead of a 304. On its next ordinary load, a browser that cached the old worker replaces it with the new file and the policy that includes 'wasm-unsafe-eval', and search works again.

Summary

Search on this site works again, for new and returning visitors alike. Looking back:

  1. Pagefind compiles its WASM inside a worker, and the worker runs under the CSP on the response it was loaded with. So 'wasm-unsafe-eval' had to go on the catch-all rule. A separate /pagefind/* rule would only have added a policy, and the catch-all policy that blocks the compile would still have applied.
  2. A CSP check that only loads pages cannot catch this error, because the WASM is not compiled until the first search. The only way to confirm search works is to search.
  3. On Vercel, a header-only change never reaches a browser that cached the worker, because the 304 it gets on revalidation carries no CSP. Stamping a hash of the policy into the file makes the file change with the policy, and that is what gets the new policy to every browser.

References

Share this article