• 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 and object-src 'none', base-uri 'self' and form-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).