diff --git a/public/_headers b/public/_headers index 6ef5728..457810c 100644 --- a/public/_headers +++ b/public/_headers @@ -1,17 +1,16 @@ # Cloudflare Pages security headers. https://developers.cloudflare.com/pages/configuration/headers/ # -# The Content-Security-Policy below is the safe, behaviour-neutral tier: it does +# The Content-Security-Policy below is a safe, behaviour-neutral tier: it does # not govern how scripts/styles/images/connections load, so it cannot break the # app, and it delivers clickjacking + base-uri/object hardening on its own. # -# At build time scripts/inject-csp.mjs (run via the `postbuild` npm hook) ADDS a -# second header to the built build/client/_headers: -# Content-Security-Policy-Report-Only carrying the full policy — a script-src -# pinned to the sha256 of each inline bootstrap script (the XSS backstop) plus -# scoped resource directives. Report-only only reports violations, so it cannot -# break the app; once it runs clean against real traffic (incl. the OAuth login), -# promote it to the enforced header below. If the build step is skipped, only -# this enforced safe tier ships — still valid, never a broken CSP. +# At build time scripts/inject-csp.mjs (run via the `postbuild` npm hook) +# REPLACES the Content-Security-Policy line in the built build/client/_headers +# with the full enforced policy — a script-src pinned to the sha256 of each +# inline bootstrap script (the XSS backstop) plus scoped resource directives. +# It was validated report-only against real traffic (public sweep + an authed +# login) before enforcing. If the build step is ever skipped, only this safe +# tier ships — still valid, never a broken CSP. /* X-Frame-Options: DENY diff --git a/scripts/inject-csp.mjs b/scripts/inject-csp.mjs index 1ca343b..be81038 100644 --- a/scripts/inject-csp.mjs +++ b/scripts/inject-csp.mjs @@ -7,24 +7,26 @@ // URL matches neither 'self' nor any hash, so it stays blocked — the XSS // backstop for the anchor sinks safeExternalUrl already guards. // -// ROLLOUT: the full strict policy ships as Content-Security-Policy-REPORT-ONLY, -// so it only reports violations and cannot break the app. The committed -// public/_headers keeps a small ENFORCED Content-Security-Policy (frame-ancestors -// etc.) so clickjacking protection is live now. Once report-only has run clean -// against real traffic (incl. the OAuth login + authed actions), promote the -// strict policy to the enforced header. This rewrites only the built artifact -// (build/client/_headers); if the step is skipped the deploy still serves the -// valid enforced safe tier from public/_headers. +// This rewrites the enforced Content-Security-Policy in the built artifact +// (build/client/_headers) with the full strict policy. The committed +// public/_headers keeps a small safe tier (frame-ancestors etc.), so if this +// step is ever skipped the deploy still serves a valid CSP, never a broken one. +// Validated report-only against real traffic (a public-route sweep + an authed +// Bluesky login) before enforcing; the only external script was Cloudflare's +// Web Analytics beacon, allowlisted in script-src below. import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; const OUT = "build/client"; const html = readFileSync(`${OUT}/index.html`, "utf8"); -// Every inline "); + expect(out).not.toContain("Report-Only"); + // the safe-tier line is gone, replaced by the full policy at the same indent + expect(out).not.toContain( + "Content-Security-Policy: frame-ancestors 'none';", + ); + expect(out).toContain(" Content-Security-Policy: default-src 'self';"); + }); + + it("hashes each inline script and skips external (src) scripts", () => { + const html = ` + + + + `; + const src = enforcedCspLine(runInjectCsp(html)); + expect(src).toContain( + "script-src 'self' https://static.cloudflareinsights.com ", + ); + // two inline scripts hashed; the src= one excluded + expect(src.match(/'sha256-[^']+'/g) ?? []).toHaveLength(2); + }); + + it("does not mistake a data-src attribute for a real src", () => { + // With a `\bsrc=` lookahead this inline script would be skipped, left + // unhashed, and blocked once the CSP is enforced. + const src = enforcedCspLine( + runInjectCsp(''), + ); + expect(src.match(/'sha256-[^']+'/g) ?? []).toHaveLength(1); + }); + + it("aborts when the built index.html has no inline scripts", () => { + expect(() => + runInjectCsp(''), + ).toThrow(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c50d35e..d588b86 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config"; // vanilla-extract build plugins from vite.config.ts. export default defineConfig({ test: { - include: ["app/**/*.test.ts"], + include: ["app/**/*.test.ts", "scripts/**/*.test.ts"], environment: "node", }, });