From a324339e421e6a9aa86df95ba701f9d07a89b8c1 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 16 Jul 2026 22:55:46 -0700 Subject: [PATCH 1/2] web: enforce the strict CSP (was report-only), allowlist CF analytics Promotes the strict policy from Content-Security-Policy-Report-Only to the enforced Content-Security-Policy: postbuild now REPLACES the safe-tier CSP line with the full policy (script-src pinned to inline-script hashes + scoped resource directives) instead of adding a report-only header. Validated in report-only against real traffic first: a headless public-route sweep and an authenticated Bluesky login both came back clean; the only report-only violation was Cloudflare's own Web Analytics beacon, now allowlisted via https://static.cloudflareinsights.com in script-src (its data POST is covered by connect-src https:). Follow-up to security review 2026-07-14 finding #3. --- public/_headers | 17 ++++++++--------- scripts/inject-csp.mjs | 29 +++++++++++++++-------------- 2 files changed, 23 insertions(+), 23 deletions(-) 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..eb7302d 100644 --- a/scripts/inject-csp.mjs +++ b/scripts/inject-csp.mjs @@ -7,14 +7,13 @@ // 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"; @@ -39,7 +38,10 @@ const csp = [ "object-src 'none'", "frame-ancestors 'none'", "frame-src 'none'", - `script-src 'self' ${hashes.join(" ")}`, + // static.cloudflareinsights.com = the Cloudflare Web Analytics beacon that + // Pages auto-injects (Cloudflare's documented CSP value). Its data POST to + // cloudflareinsights.com is covered by connect-src https: below. + `script-src 'self' https://static.cloudflareinsights.com ${hashes.join(" ")}`, // Mantine/emotion apply inline style attributes; vanilla-extract emits static CSS. "style-src 'self' 'unsafe-inline'", // bsky avatars, flag data URIs, maplibre tiles (canvas/blob). @@ -58,13 +60,12 @@ const cspLine = /^(\s*)Content-Security-Policy:.*$/m; if (!cspLine.test(headers)) { throw new Error("inject-csp: no Content-Security-Policy line found in _headers"); } -// Leave the committed enforced safe-tier CSP in place; add the full strict -// policy as report-only right below it (same indentation). Report-only can't -// block anything, so this is safe to ship to production untested routes. +// Replace the committed safe-tier Content-Security-Policy with the full strict +// enforced policy (indentation preserved via $1). writeFileSync( headersPath, - headers.replace(cspLine, `$&\n$1Content-Security-Policy-Report-Only: ${csp}`), + headers.replace(cspLine, `$1Content-Security-Policy: ${csp}`), ); console.log( - `inject-csp: added report-only strict CSP (script-src pinned to ${hashes.length} inline-script hashes)`, + `inject-csp: enforced strict CSP (script-src pinned to ${hashes.length} inline-script hashes)`, ); From 145068a3cab8981619c6f441edb3b293f2577a53 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 16 Jul 2026 23:44:28 -0700 Subject: [PATCH 2/2] web: harden inject-csp (regex + $-safe replace) and add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR#7 review: - The inline-script lookahead now requires whitespace before src= (\ssrc= not \bsrc=), so a data-src / *-src attribute isn't mistaken for a real src and skipped — a skipped inline script has no hash and would be blocked once the CSP is enforced. - The _headers rewrite uses a function replacement so a $ in the policy can't be read as a String.replace token. - New scripts/inject-csp.test.ts runs the real postbuild against throwaway build/client fixtures: inline scripts hashed, external src excluded, data-src not misclassified, enforced (no report-only) with indentation preserved, and aborts when no inline script is found. vitest include broadened to scripts/. --- scripts/inject-csp.mjs | 14 ++++--- scripts/inject-csp.test.ts | 81 ++++++++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 scripts/inject-csp.test.ts diff --git a/scripts/inject-csp.mjs b/scripts/inject-csp.mjs index eb7302d..be81038 100644 --- a/scripts/inject-csp.mjs +++ b/scripts/inject-csp.mjs @@ -20,10 +20,13 @@ 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", }, });