A strict, hash-pinned Content-Security-Policy
- Status: Accepted
- Date: 2026-07-12
- Deciders: Engineering, Security
Context
Section titled “Context”SECURITY.md and the non-functional requirements
describe the site as shipping “a strict, reviewed Content-Security-Policy.” The
policy in apps/web/public/_headers, however, carried 'unsafe-inline' in
both script-src and style-src:
script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://assets.calendly.com https://static.cloudflareinsights.comstyle-src 'self' 'unsafe-inline' https://assets.calendly.com'unsafe-inline' in script-src permits any injected inline <script> to
run, which defeats the XSS protection that is the point of a CSP. Under the
accepted definition — the Google/W3C strict-CSP guidance,
nonce- or hash-based with no 'unsafe-inline' — this policy was not
strict, and the gap was disprovable with a single curl -I. For the firm’s own
security page, an inaccurate claim is itself a defect (AGENTS.md §7).
The site is output: 'static' served by Cloudflare Workers Static Assets, with
the CSP delivered as a response header via the static _headers file. That
constraint rules out the options that suit dynamic origins:
- Per-request nonces (
strict-dynamic+ a fresh nonce per response) need a server in the request path. Our HTML is static assets served directly by the edge; routing every page through Worker code (run_worker_first+HTMLRewriter) to stamp nonces would add per-request CPU, latency, and cost to an intentionally zero-JS, statically-served site — a poor trade. - Cloudflare Page Shield manages CSP from the dashboard, but it generates
host-allowlist directives and monitors/report-only; it does not emit the
nonces or hashes that remove
'unsafe-inline'. It is a monitoring and tamper-alerting layer, not a mechanism for a strict policy, and it is dashboard-managed, at odds with our git + Terraform posture. - Astro’s built-in CSP (
csp: true, stable in Astro 6+) hashes inline scripts at build, but delivers them via a per-page<meta>tag. A<meta>CSP cannot expressframe-ancestors/report-uri, and — decisively — it does not change the response header, socurl -Iwould still show'unsafe-inline'. The Cloudflare adapter’sexperimentalStaticHeaders(which would write hashes into_headers) is not yet shipped.
Two site-specific obstacles stood between the old policy and a strict one:
- Inline style attributes. Six surfaces stamped per-instance CSS custom
properties as
style=""attributes (stack-wall brand/icon vars, the tools-band depth tiers, stat-band discipline tints, logo-marquee geometry, the team-page starfield, the hero pit SVG’s stroke lengths). CSP can only pin style attributes with'unsafe-hashes'— itself a weakening expression — so hashes alone couldn’t cover them. - The Calendly popup widget.
widget.js/widget.cssfromassets.calendly.compower the contact page’s booking popup, and Calendly’s embed officially requires'unsafe-inline'(and'unsafe-eval') with no nonce/hash support offered — a vendor dead end for any strict policy while their code runs in our document.
Decision
Section titled “Decision”The CSP is made fully strict: no 'unsafe-*' source expression anywhere.
Every inline <script> and <style> is pinned by SHA-256 hash, generated at
build:
apps/web/public/_headerscarries__CSP_SCRIPT_HASHES__and__CSP_STYLE_HASHES__placeholders. Apostbuildstep,apps/web/scripts/inject-csp-hashes.mjs, hashes every executable inline<script>and every<style>element in the emitteddist/client/**/*.htmland expands the tokenized template in the deployeddist/client/_headers. It fails the build if a placeholder is missing or no inline scripts/styles are found, so a hashless (broken) strict CSP can never ship silently.- The policy is emitted per route family, not as one global rule:
Cloudflare caps
_headerslines at 2000 characters (API error 100324), which a global hash union exceeds — and page-scoped policies are tighter anyway, since a page can only use its own inline content. The rules are overlap-free by construction:/*carries no CSP, and every HTML route family (/,/404,/work+/work/*, …) gets one plain rule with only its own hashes. A first iteration used a/*fallback that family rules detached (! Content-Security-Policy); local workerd (wrangler dev) honored it, but the production_headerspipeline served the fallback policy anyway — so nothing in this design may depend on detach or rule precedence. Unmatched paths, which serve404.htmlwith no matching rule, are covered by a<meta http-equiv>CSP the injector writes into404.htmlitself (fetch directives only;frame-ancestorsis meta-invalid and is stripped —X-Frame-Optionsand HSTS still arrive via/*’s other headers). The injector fails the build before Cloudflare’s line (2000 chars) or rule-count (100) limits can bite. - Style attributes are eliminated rather than excused. Components that
stamped per-instance custom properties inline now emit generated
per-instance
<style>blocks targeted by a deterministic scope class (src/lib/style-scope.ts— a pure hash of the instance’s data, so identical props yield identical blocks and stable CSP hashes). Enum-keyed styling (stat-band discipline tints) usesdata-*attributes with one static rule per value. The hero SVG’s hand-authored constants moved into the page’s scoped stylesheet. - Calendly’s code is evicted from our document. The booking popup is a
native
<dialog>we own, framing a lazy iframe tocalendly.com— CSP governs our document, not the iframe’s interior, so the booking flow is unchanged whileassets.calendly.comleavesscript-srcandstyle-srcandcalendly.comleavesconnect-srcentirely. Onlyframe-src https://calendly.comremains. The iframe’ssrcis set on first open, so no request reaches Calendly until a visitor asks to book (a privacy and page-weight win over the always-loaded widget). - One vetted runtime carve-out. cobe 2.0.1 (the
/teamglobe) injects a constant:root{}<style>for its anchor-positioning feature; its re-writes also pass through an empty-text state that CSP checks separately. Both constants are pinned as two static hashes in_headerswith a comment; an empty stylesheet styles nothing, and mutating it afterward is CSSOM, which the lockedscript-srcalready gates. - External scripts (Turnstile, Cloudflare Insights) stay covered by the host
allowlist;
strict-dynamicis deliberately not used, so thosesrc-based loaders keep working. JSON-LD and import-map blocks are not governed byscript-srcand are skipped. The same pass setobject-src 'none'explicitly and tightenedimg-srcto'self' data:(data:is required by the globe’s WebGL texture; the oldhttps:any-host wildcard matched nothing the site renders).
The contract is enforced three ways: unit tests on the hash collectors
(src/lib/csp-hashes.test.ts), the postbuild failure modes above, and a
Playwright e2e spec (tests/e2e/csp.spec.ts) that applies the built header to
every backbone route and asserts zero CSP violations, that the policy
contains no unsafe-*, and that served HTML ships no style="" attributes.
Consequences
Section titled “Consequences”- The deployed
Content-Security-Policyheader carries no'unsafe-*'anywhere; the “strict” claim is true and curl-verifiable. - Adding, removing, or editing an inline script or style re-pins its hash on
the next build — no manual
_headersmaintenance, no drift. A new inline script/style without a hash, a reintroduced style attribute, or a dependency changing its runtime injection is caught by the e2e CSP gate before merge. - Per-instance styling must use the generated-block pattern
(
src/lib/style-scope.ts) or enum-keyeddata-*rules — neverstyle=""attributes. This is now a build-verified convention, not a preference. - Third-party UI code does not run in the document. Embeds that cannot meet the policy (Calendly-style widgets) are integrated as iframes; their pages are governed by their own CSP inside the frame.
astro devdoes not apply_headers, so the strict policy is exercised against the built site (e2e, preview, deploy), consistent with how the other headers already behave.- No new runtime dependency and no per-request cost: the work happens once at build, and pages are still served as pure static assets.
- If we later adopt Cloudflare Page Shield in report-only, it complements this policy as a monitoring layer; it does not replace the build-time hashing.
Alternatives considered
Section titled “Alternatives considered”- Reword the docs, drop “strict.” Truthful and zero-risk, but it forfeits a real security property the static architecture can support cheaply. Rejected in favour of actually being strict.
- Per-request nonces via a Worker. The strongest model, but it regresses the static-serving architecture (ADR-0002, ADR-0006). Rejected.
- Cloudflare Page Shield policies. Host-allowlist + monitoring only; cannot
remove
'unsafe-inline'. Kept in reserve as a complementary monitor. - Astro built-in CSP (
<meta>) orastro-static-headers. The former does not fix the response header; the latter does not integrate with Astro’s CSP hashing (it capturesAstro.response.headersonly), needs aprerenderEnvironment: 'node'adapter change, and adds a third-party build dependency against the repo’s hermetic-build preference. A small in-repo postbuild step is simpler and fully owned. - Keep
style-src 'unsafe-inline'as a documented exception. Defensible (style injection is a far weaker vector than script injection) and common practice — but the refactor cost was bounded, the Calendly dependency was the only true blocker, and a policy with zerounsafe-*is categorically easier to defend on a security page than one with a footnote. 'unsafe-hashes'for the style attributes. Narrower than'unsafe-inline', but it still weakens the policy and reads as exactly the kind of qualifier the audit flagged. Eliminating the attributes was cheap enough to make it moot.- Patching cobe (pnpm patch) to remove its stylesheet injection. Total, but adds patch-maintenance for a constant, harmless string; two documented static hashes achieve the same containment with less machinery.