Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -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
Expand Down
39 changes: 22 additions & 17 deletions scripts/inject-csp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script> (no src= attribute). External /assets bundles carry a
// src and are covered by 'self'.
// Every inline <script> (no src attribute). External /assets bundles carry a
// src and are covered by 'self'. The lookahead requires whitespace before
// `src=`, so a data-src (or any other *-src) attribute isn't mistaken for a
// real src and wrongly skipped — under the enforced CSP a skipped inline
// script has no hash and would be blocked.
const hashes = [];
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
const re = /<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
for (let m; (m = re.exec(html)); ) {
const digest = createHash("sha256").update(m[1], "utf8").digest("base64");
hashes.push(`'sha256-${digest}'`);
Expand All @@ -39,7 +41,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).
Expand All @@ -58,13 +63,13 @@ 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. A function replacement inserts the policy literally, so any
// `$` in it can't be read as a String.replace token ($&, $1, $`, …).
writeFileSync(
headersPath,
headers.replace(cspLine, `$&\n$1Content-Security-Policy-Report-Only: ${csp}`),
headers.replace(cspLine, (_line, indent) => `${indent}Content-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)`,
);
81 changes: 81 additions & 0 deletions scripts/inject-csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { execFileSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";

const SCRIPT = resolve(process.cwd(), "scripts/inject-csp.mjs");
const HEADERS =
"/*\n X-Frame-Options: DENY\n" +
" Content-Security-Policy: frame-ancestors 'none'; base-uri 'self'; object-src 'none'\n";

// Run the real postbuild script against a throwaway build/client fixture and
// return the rewritten _headers.
function runInjectCsp(indexHtml: string, headers = HEADERS): string {
const dir = mkdtempSync(join(tmpdir(), "inject-csp-"));
try {
const out = join(dir, "build", "client");
mkdirSync(out, { recursive: true });
writeFileSync(join(out, "index.html"), indexHtml);
writeFileSync(join(out, "_headers"), headers);
execFileSync(process.execPath, [SCRIPT], { cwd: dir, stdio: "pipe" });
return readFileSync(join(out, "_headers"), "utf8");
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

function enforcedCspLine(headers: string): string {
const line = headers
.split("\n")
.find((l) => l.trimStart().startsWith("Content-Security-Policy:"));
if (!line) throw new Error("no enforced CSP line in output");
return line;
}

describe("inject-csp postbuild", () => {
it("enforces the strict policy: replaces the safe tier, no report-only, keeps indent", () => {
const out = runInjectCsp("<script>console.log(1)</script>");
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 = `<html><head>
<script src="/assets/app.js"></script>
<script data-mantine-script="true">console.log(1)</script>
<script type="module">import "/entry";</script>
</head></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('<script data-src="ignored">console.log(2)</script>'),
);
expect(src.match(/'sha256-[^']+'/g) ?? []).toHaveLength(1);
});

it("aborts when the built index.html has no inline scripts", () => {
expect(() =>
runInjectCsp('<script src="/assets/only-external.js"></script>'),
).toThrow();
});
});
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
});
Loading