Skip to main content

A static-site CSP that doesn't break Pagefind

Illustrated receipt card summarizing: A static-site CSP that doesn't break Pagefind

A working CSP on a static Astro site with Pagefind search, deployed to Cloudflare Pages. The whole config fits in fourteen lines of public/_headers. Two of the directives are not in any tutorial: 'wasm-unsafe-eval' in script-src and worker-src 'self' blob: — both Pagefind requirements that fail silently if missing. One of them, 'unsafe-inline', looks like a smell and is actually the right tradeoff for this threat model. The post is the file, the reasoning per directive, and the one verification step you can do in DevTools that catches the silent break before deploy.

The file that ships

public/_headers — Cloudflare Pages reads it natively at deploy time, no middleware, no per-request runtime cost:

/*
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=(), browsing-topics=()
  Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests
  Cache-Control: public, max-age=3600, s-maxage=86400

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

/pagefind/*
  Cache-Control: public, max-age=86400, s-maxage=2592000

834 bytes on disk. Build copies it to dist/_headers, Cloudflare Pages picks it up. No middleware, no Workers, no runtime tax. The cache-control split at the bottom is a separate concern — it lifts edge hit rate by giving immutable hashed assets a one-year TTL while keeping the HTML revalidated hourly.

The two Pagefind directives nobody documents

If you ship Pagefind without these two pieces, search breaks on /skills/ with no console UI to surface the failure — the page loads, the search box renders, and queries return zero results forever.

'wasm-unsafe-eval' in script-src. Pagefind compiles a WebAssembly index at runtime to do its actual searching. Browsers gate WebAssembly behind a CSP token that’s separate from 'unsafe-eval' — the strict variant 'wasm-unsafe-eval' permits compiled wasm execution without permitting eval() on JavaScript strings. If you write a CSP without it, Pagefind throws on instantiation and the search UI silently swallows the error.

worker-src 'self' blob:. Pagefind spawns a Web Worker for index search, and it instantiates that worker from a blob: URL — not a same-origin script. Without blob: in worker-src, the worker construction fails and the same silent dead-end applies. The 'self' part covers the loader script; the blob: part covers the worker the loader creates.

Neither token shows up in the upstream Pagefind docs as “you need this in your CSP.” Both are inferred from the bundled JS once you read it.

What 'unsafe-inline' actually costs

The conventional advice on 'unsafe-inline' is: don’t. Use nonces. Use hashes. Anything else.

That’s correct advice for an app surface — anywhere user-supplied content reaches a <script> or a style= attribute, 'unsafe-inline' is what turns a stored XSS into RCE-on-the-browser. The threat model on a static content blog is different:

  • No forms taking user input rendered back to the page.
  • No comments, no auth, no sessions, no cookies, no localStorage of secrets.
  • No user-generated content of any kind. Every byte rendered comes from a markdown file or a layout component, both authored by the operator and committed to git.
  • Astro static output cannot ship per-request nonces — there is no request, only a pre-built HTML file copied to the edge. The nonce would be baked in at build and would be the same forever; that’s strictly worse than no nonce because it implies a guarantee the server isn’t providing.
  • Hash-based CSP works but breaks every time you edit any inline script or style. The site has Astro-emitted inline <style> blocks for component CSS, JSON-LD inline <script type="application/ld+json"> blocks, and the Pagefind UI bootstrap inline <script>. Touch any of those — the hash changes — the CSP rejects the page until you rebuild and republish the header. A four-file deploy becomes a five-file deploy.

The XSS surface that 'unsafe-inline' would protect against doesn’t exist on this site. The maintenance cost of hash-based CSP is non-zero forever. The right answer is to ship with 'unsafe-inline' and document the assumption — which is this post.

The day a form, an auth flow, or a comment system shows up on this site, the assumption breaks and the directive comes out the same day. Until then, paying the complexity tax for a defense against a non-existent attack is wasted budget.

One thing to verify before adopting this stance on your own static site: grep for any <script> block that interpolates a value from anywhere outside consts.ts or markdown frontmatter you control. JSON-LD with operator-supplied data is fine. JSON-LD that interpolates a query param is not — that’s the XSS path even on a “static” site, and 'unsafe-inline' lets it through. The recently-shipped commit on this site that escapes < to &lt; in the JSON-LD set:html output is exactly this concern: any future content with </script> inside its description field would otherwise break out of the JSON-LD block. The escape is the discipline; the CSP is just one layer.

frame-ancestors 'none' and X-Frame-Options: DENY overlap intentionally

Both directives say the same thing — no embedding in iframes — and the modern advice is “use frame-ancestors, drop X-Frame-Options.” That advice is correct on a strict-CSP modern-only site. The pragmatic version, which is what ships here, keeps both:

  • frame-ancestors 'none' is part of CSP and respected by every modern browser. CSP fails closed: if a browser doesn’t understand a directive, it doesn’t apply it.
  • X-Frame-Options: DENY is the older header. Pre-CSP browsers don’t honor frame-ancestors and would otherwise embed the page.

The overlap costs five bytes of header per response. The single-source-of-truth purity isn’t worth the bytes you’d save. Ship both.

How to verify Pagefind didn’t break

The silent-break failure mode is the dangerous one. The fix:

  1. Build locally: npm run build. The build runs astro build && pagefind --site dist --glob "skills/**/*.html" end-to-end; a missing CSP header at this stage wouldn’t break the build because the headers don’t apply to the dev server.
  2. Preview the built output: npm run preview. The Astro preview server doesn’t honor _headers either — Cloudflare Pages does. Preview is for the HTML, not the headers.
  3. Push to a Cloudflare Pages preview deployment (push to a branch other than main if you have preview deploys configured, or use the dashboard’s “create preview” option). The preview URL will serve _headers.
  4. Open /skills/ on the preview URL with DevTools open. Type any query in the search box. If the dropdown populates with results within ~200ms, the wasm and worker directives are correct. If results stay empty and the Console shows Refused to compile or instantiate WebAssembly module because 'wasm-unsafe-eval' is not an allowed source or Refused to create a worker from 'blob:...', the CSP is missing one or both directives.
  5. Either error message is the verification. If the Console is silent and search still doesn’t work, look at the Network tab for a /pagefind/pagefind.js request that’s blocked or a wasm fetch returning 200 but never executing.

The verification step that doesn’t work: curl -I https://your-preview-url/. The CSP header will show up in curl even if it’s silently breaking Pagefind, because the breakage happens at script execution, not at header negotiation.

What’s not in this CSP and why

A few directives you might expect that aren’t here:

  • report-uri / report-to. These would post CSP violations to a collection endpoint. There is no endpoint, and small static sites without Sentry / a CSP report ingester are usually better off without one — the noise from browser extensions injecting scripts is high, the signal is low until you have real attack volume to filter from. Add it the day you have somewhere to send the reports.
  • script-src-elem / script-src-attr. These are finer-grained variants of script-src. The single script-src directive applies to both. Add the variants when you have a reason to differentiate inline-attribute behavior from <script> element behavior.
  • A style-src-attr 'unsafe-inline' carve-out separate from style-src. Same logic — one style-src covers it.
  • prefetch-src / navigate-to. Both are deprecated or never-shipped. Skip.
  • trusted-types. This is the right answer for app surfaces with DOM-XSS sinks. On a static content site with no innerHTML interpolation of user data, it’s overhead without payoff.

What this swapped in for

Before this _headers file shipped, a security-framed audit run with /seo-technical flagged four high-priority gaps on the live site: no HSTS, no CSP, no X-Frame-Options, no Permissions-Policy. Cloudflare Pages had set nosniff and Referrer-Policy by default; the four missing ones were on the operator. One file closed all four in a single 4.1-second build. A follow-up commit added nosniff and Referrer-Policy explicitly so the file is the single source of truth instead of relying on Cloudflare defaults that could change. Three commits total over the evolution: ship the file, add cache-control, add the explicit defaults.

The lesson worth keeping: on a Cloudflare Pages static site, the right surface for security headers is _headers, not functions/_middleware.js, not per-page <meta http-equiv>, not Workers. One file, all surfaces covered, zero runtime cost. The middleware file is the right surface for redirects (the pages.dev-to-canonical-domain redirect lives there), not headers.