- Oct 6, 2026
- 4 min read
A Content Security Policy on Cloudflare Pages, Tested in a Real Browser
My audit tool gave this site and the consulting page 70 on security: no
Content-Security-Policy, no Strict-Transport-Security, no
X-Frame-Options. On Cloudflare Pages, all of them go in one file,
public/_headers, which Astro copies verbatim to dist/.
Adding headers is easy. The hard part of a CSP is not breaking the site.
Step 1: list what the site really loads
A CSP is an allowlist, so start from the built site, not from memory:
# Every external origin referenced from HTML
grep -rhoE '(src|href|action)="https?://[^/"]+' dist --include=*.html \
| sort | uniq -c | sort -rn
# Scripts and inline scripts per page
grep -rhoE '<script[^>]*>' dist --include=*.html | sort | uniq -c | sort -rn
For this site that turned up more than I expected:
- the cookieless analytics script, on another domain;
- Google Fonts on every page (since removed, see self-hosting the fonts);
- 143 inline
<script type="module">blocks: Astro inlines small scripts; - one old demo page loading Alpine.js and Tailwind from CDNs.
The consulting page added a Cal.com booking embed (a script plus an iframe) and a YouTube video.
Step 2: write the policy
/*
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=()
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' <analytics hosts>; connect-src 'self' <analytics hosts>; frame-src https:; upgrade-insecure-requests
Choices worth explaining:
'unsafe-inline'for scripts is a compromise. Those inline module scripts would need a hash each, regenerated on every build. With no user-generated content andobject-src 'none',base-uri 'self'andform-action 'self'in place, I took the simpler policy.form-action 'self'fits because every form posts to a same-origin endpoint (one forms Worker for every site).img-src https:is loose on purpose: articles embed images from many hosts, and images can’t run code.
Step 3: override the policy for one path
The mortgage calculator demo uses Alpine.js, which evaluates its
expressions with new Function, so it needs 'unsafe-eval'. I didn’t want
that on every page.
In _headers, every matching rule applies. Adding a second
Content-Security-Policy for one path would send two policies, and
browsers enforce both, so the stricter one still blocks Alpine. The !
prefix removes a header set by an earlier rule:
/presentations/mortage-calculator*
! Content-Security-Policy
Content-Security-Policy: default-src 'self'; … script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com 'unsafe-eval'; …
The consulting page’s Slidev decks under /presentations/* got the same
treatment.
wrangler pages dev applies _headers locally, so you can confirm the
override:
npx wrangler pages dev dist --port 8799
curl -sI http://localhost:8799/presentations/mortage-calculator | grep -i content-security
Step 4: let a browser tell you what breaks
Browsers report CSP violations in the console, so a small Playwright script can collect them across representative pages:
import { chromium } from 'playwright';
const [base, ...paths] = process.argv.slice(2);
const browser = await chromium.launch();
let bad = 0;
for (const p of paths) {
const page = await browser.newPage();
const errs = [];
page.on('console', (m) => { if (/Content Security Policy|Refused/i.test(m.text())) errs.push(m.text()); });
page.on('pageerror', (e) => errs.push('pageerror: ' + e.message));
const res = await page.goto(base + p, { waitUntil: 'load' });
await page.waitForTimeout(800);
console.log(`${res.status()} ${p} violations=${errs.length}`);
errs.forEach((e) => console.log(' ', e));
bad += errs.length;
await page.close();
}
await browser.close();
process.exit(bad ? 1 : 0);
node csp.mjs http://localhost:8799 / /privacy /terms /contact \
/support/articles/some-article-with-a-diagram /presentations/mortage-calculator /nonexistent
Choose pages by type, not at random: the homepage, an article with a Mermaid
diagram, a page with a form, each demo, and the 404 page. Then check a
feature works, not just that nothing was refused: that the diagram rendered
an <svg>, that window.Alpine exists, and that the booking iframe
appeared after scrolling.
One false alarm worth knowing: the checker reported a Slidev deck’s
stylesheets as “Refused… MIME type text/html”. That wasn’t the policy. The
deck asks for /assets/… instead of its own folder, gets the 404 page back,
and nosniff refuses HTML served as CSS. It was already broken in
production; the headers just made it visible.
Result
Security went from 70 to 100 on both sites, with zero violations on every page type and every feature checked. Most of the work was in steps 1 and 4; writing the policy itself was the quick part.
Takeaways
- Build the allowlist from
dist/, not from what you remember adding. - Use
!to replace a header for one path. Two CSP headers means both are enforced. - Test in a browser before deploy, by page type, and check that features still work.
- Commit
_headers. If it isn’t committed, a clean build ships without it (the checklist I use).