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, immutableTwo blocks: /* sets security headers on every response, and /_astro/* sets aggressive caching on
fingerprinted assets. Take them in turn.
The security header set
Section titled “The security header set”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 declaredContent-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 asRefereronly 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.preloadopts 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 CSP, directive by directive
Section titled “The CSP, directive by directive”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:
| Directive | Value | Meaning |
|---|---|---|
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.com | Fonts from self or Google Fonts’ font host. |
style-src | 'self' 'unsafe-inline' https://fonts.googleapis.com | Styles 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, immutableAstro 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.
Check your understanding
Section titled “Check your understanding”- The
_headersfile lives inpublic/. Trace how it ends up applied to live responses, and say who applies it. - Pick any three security headers and, for each, name the specific attack it closes off.
- 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'? - Why is it safe to cache
/_astro/*for a full year withimmutable, when caching HTML that long would be a bug? - The CSP includes
'unsafe-inline'for scripts and styles. Is that a mistake? Justify the answer the way the book does.
Show answers
public/is copied verbatim intodist/by the Astro build, so_headersships 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.- Any three, e.g.:
X-Frame-Options: DENY/frame-ancestors 'none'→ clickjacking;X-Content-Type-Options: nosniff→ MIME-sniffing turning a file into executable script;Strict-Transport-Security→ protocol downgrade / SSL-strip by forcing HTTPS;Referrer-Policy→ referrer leakage of URLs to third parties; CSPconnect-src 'self'→ exfiltration to attacker-controlled endpoints. - Pagefind (Starlight’s search) compiles a WebAssembly module in the browser, which CSP treats as dynamic
code execution; the old
script-srchad no WASM permission, so instantiation threwCompileError.'wasm-unsafe-eval'permits WASM compilation only;'unsafe-eval'would also alloweval()of arbitrary JS strings — a far larger attack surface. Granting exactly the needed capability is least privilege. - 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. - 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.