Skip to content

Headers, CSP & Caching

A static site serves files, but how it serves them — with which headers — is still a real security and performance decision. Cloudflare Pages reads a special file, apps/<app>/public/_headers, and applies its rules to matching responses. Because it lives in public/, the build copies it verbatim into dist/, so it ships with the site. This one small file is where HTTP & TLS from Part 2 and shift-left security from Part 8 land on a static deploy. Here is the whole file for this book:

/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Permissions-Policy: geolocation=(), microphone=(), camera=()
Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'
/_astro/*
Cache-Control: public, max-age=31536000, immutable

Two blocks: /* sets security headers on every response, and /_astro/* sets aggressive caching on fingerprinted assets. Take them in turn.

Each header closes off a specific attack, deny-by-default:

  • X-Content-Type-Options: nosniff — stop the browser from guessing (MIME-sniffing) a file’s type. Sniffing can turn an innocent upload into executable script; this forces the declared Content-Type.
  • X-Frame-Options: DENY — no one may load this site in an <iframe>. Kills clickjacking, where an attacker frames your page invisibly over their own.
  • Referrer-Policy: strict-origin-when-cross-origin — send the full URL as Referer only within your own origin; cross-site, send just the origin. Stops path/query leaking to third parties.
  • Strict-Transport-Security (HSTS) — for two years, the browser must use HTTPS for this domain and all subdomains, no exceptions. preload opts into the browser-shipped HSTS list so even the first visit is forced onto HTTPS. This is the TLS lesson enforced by policy.
  • Permissions-Policy: geolocation=(), microphone=(), camera=() — explicitly disable powerful device APIs the site never uses. Empty () = allowed for no origin at all.

None of these needs a server to run; they are static assertions the CDN attaches to every response. That is the theme of the whole Part — safety by configuration, not by a running guard.

The Content-Security-Policy is the big one: a whitelist telling the browser which sources of content it is allowed to load and execute. Anything not listed is blocked. Reading it left to right:

DirectiveValueMeaning
default-src'self'Default: load resources only from this origin.
base-uri'self'Lock <base> to this origin — stops a base-tag injection redirecting every relative URL.
form-action'self'Forms may only submit back to this origin.
frame-ancestors'none'The CSP-era, stronger twin of X-Frame-Options: DENY.
img-src'self' data: https:Images from self, inline data: URIs, or any HTTPS source.
font-src'self' https://fonts.gstatic.comFonts from self or Google Fonts’ font host.
style-src'self' 'unsafe-inline' https://fonts.googleapis.comStyles from self and Google Fonts’ CSS; 'unsafe-inline' because Starlight + MUI/Emotion inject inline styles.
script-src'self' 'unsafe-inline' 'wasm-unsafe-eval'Scripts from self; inline scripts allowed; and WASM compilation allowed.
connect-src'self'fetch/XHR/WebSocket may talk only to this origin.

An honest note the repo’s own DEPLOYMENT.md makes: 'unsafe-inline' on styles and scripts is a real loosening, present because Starlight and the MUI/Emotion component layer emit inline styles and a little inline script. The stricter end-state is self-hosting the font and switching to hashed or nonce’d scripts. The CSP here is a pragmatic, documented trade-off — not the theoretical maximum — and the book says so rather than pretending otherwise.

The wasm-unsafe-eval fix — a real debugging story

Section titled “The wasm-unsafe-eval fix — a real debugging story”

That last token in script-src'wasm-unsafe-eval' — is in the file because of an actual bug fixed in this repo, and it is the most instructive line on the page.

Starlight’s built-in search is powered by Pagefind, which — to search a large index fast on the client — compiles a WebAssembly module in the browser. Instantiating WASM counts, under CSP, as a form of dynamic code execution. The original policy had script-src 'self' 'unsafe-inline' — no WASM permission — so every deployed site threw a CompileError and search was silently broken on all eight Starlight books.

The fix was not to reach for the blunt 'unsafe-eval' (which would also permit eval() of arbitrary JavaScript strings — a big attack surface). It was the narrow keyword 'wasm-unsafe-eval', which permits WASM compilation only, not JS eval:

script-src 'self' 'unsafe-inline'
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'

This is least privilege as a one-line diff: grant the exact capability the feature needs (compile WASM) and nothing more (still no arbitrary eval). It is also a perfect small illustration of why a CSP is deny-by-default — the policy did its job by blocking something unexpected; the work was understanding what to allow, precisely.

Immutable caching for fingerprinted assets

Section titled “Immutable caching for fingerprinted assets”

The second block is pure performance:

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

Astro writes bundled JS/CSS into /_astro/ with a content hash in every filename (e.g. page.a1b2c3.js). A fingerprinted name means the file’s contents can never change under a given URL — change the contents and you get a new name. So it is safe to tell browsers to cache it for a year (31536000 seconds) and never even revalidate (immutable). When you deploy new content, the HTML references new hashed filenames, so returning visitors fetch only what actually changed and everything else is a local cache hit. This is the cache-busting pattern done right: long, aggressive caching made safe by content-addressed names — the same “the name is the hash” idea you met with container image digests and, in the sibling Bitcoin book, with Merkle roots. The HTML itself is not in this block, so it is fetched fresh and picks up the new asset names immediately.

  1. The _headers file lives in public/. Trace how it ends up applied to live responses, and say who applies it.
  2. Pick any three security headers and, for each, name the specific attack it closes off.
  3. Search was broken on every deployed site with a CompileError. What was the root cause, and why is 'wasm-unsafe-eval' the right fix rather than 'unsafe-eval'?
  4. Why is it safe to cache /_astro/* for a full year with immutable, when caching HTML that long would be a bug?
  5. The CSP includes 'unsafe-inline' for scripts and styles. Is that a mistake? Justify the answer the way the book does.
Show answers
  1. public/ is copied verbatim into dist/ by the Astro build, so _headers ships inside the deployed folder. Cloudflare Pages reads that file and applies its rules to matching responses at the edge. No server of yours is involved — it’s CDN configuration delivered as a file.
  2. Any three, e.g.: X-Frame-Options: DENY / frame-ancestors 'none'clickjacking; X-Content-Type-Options: nosniffMIME-sniffing turning a file into executable script; Strict-Transport-Securityprotocol downgrade / SSL-strip by forcing HTTPS; Referrer-Policyreferrer leakage of URLs to third parties; CSP connect-src 'self'exfiltration to attacker-controlled endpoints.
  3. Pagefind (Starlight’s search) compiles a WebAssembly module in the browser, which CSP treats as dynamic code execution; the old script-src had no WASM permission, so instantiation threw CompileError. 'wasm-unsafe-eval' permits WASM compilation only; 'unsafe-eval' would also allow eval() of arbitrary JS strings — a far larger attack surface. Granting exactly the needed capability is least privilege.
  4. Files under /_astro/ have a content hash in the filename, so a given URL’s bytes can never change (new content → new name). Caching them forever is safe and can’t serve stale content. HTML has a stable URL whose contents do change every deploy, so caching it a year would pin visitors to an old page and hide the new asset names.
  5. It is a documented, pragmatic trade-off, not a silent mistake. Starlight + MUI/Emotion inject inline styles and a little inline script, so 'unsafe-inline' is currently required for the site to render. The book names the stricter end-state (self-host the font, use hashed/nonce’d scripts) rather than pretending the CSP is maximal — honesty about the residual risk is the point.