From 995d8f08e513edab45a07f4099a92fd92bc3edb7 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 11 Sep 2026 10:52:46 -0400 Subject: [PATCH] test(policy-site): hold the built page to its deployed CSP check-csp.mjs reads the policy from infra/policy-site/main.tf and fails on inline script or style, string-built JavaScript, or any resource the policy does not grant from the page's own origin. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: If802e3749d2ad89d1997e6662e5d8c624c157d95 --- policy-site/tests/check-csp.mjs | 291 ++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 policy-site/tests/check-csp.mjs diff --git a/policy-site/tests/check-csp.mjs b/policy-site/tests/check-csp.mjs new file mode 100644 index 00000000..30ca0db6 --- /dev/null +++ b/policy-site/tests/check-csp.mjs @@ -0,0 +1,291 @@ +// The built page and the policy it is served under are a pair, and neither +// half can be checked alone. The Content-Security-Policy lives in +// infra/policy-site/main.tf, so a build that starts needing something the +// policy does not grant passes every other test and then fails in an +// operator's browser, where the only trace is a console warning. +// +// So this reads the policy from infra/ (never a copy of it) and holds dist/ to +// it: +// +// - no inline script or style of any shape, since no directive grants +// 'unsafe-inline'; +// - no JavaScript built from a string, since script-src grants +// 'wasm-unsafe-eval' and not 'unsafe-eval'; +// - every resource the HTML and CSS load comes from the page's own origin, +// or is a data: URL where the governing directive allows one, and that +// directive grants it. +// +// It uses node's standard library only, so it runs whatever policy-site/ +// installs. Its helpers live in this file for the same reason. +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(siteRoot, ".."); +const distDir = join(siteRoot, "dist"); +const mainTf = join(repoRoot, "infra", "policy-site", "main.tf"); + +let failures = 0; +function fail(message) { + failures += 1; + console.error(`FAIL: ${message}`); +} + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(full)); + else out.push(full); + } + return out; +} + +try { + statSync(distDir); +} catch { + throw new Error(`${distDir} does not exist — run "npm run build" in policy-site/ before the tests`); +} + +// The policy as the distribution sends it: the `content_security_policy` +// local is a join of one quoted directive per line. +const tf = readFileSync(mainTf, "utf8"); +const block = tf.match(/content_security_policy\s*=\s*join\("\s*",\s*\[([\s\S]*?)\]\s*\)/); +const domain = tf.match(/^\s*domain\s*=\s*"([^"]+)"/m)?.[1]; +if (!block || !domain) { + throw new Error(`${relative(repoRoot, mainTf)} no longer has a content_security_policy = join(...) ` + + "local and a domain local; this test reads the deployed policy from there"); +} +const policy = [...block[1].matchAll(/"([^"]*)"/g)].map(([, line]) => line).join(" "); +const origin = `https://${domain}`; + +const directives = new Map(); +for (const part of policy.split(";")) { + const [name, ...sources] = part.trim().split(/\s+/); + if (name) directives.set(name.toLowerCase(), sources); +} + +// The directive a browser consults for a resource type, falling back as CSP +// Level 3 says. base-uri and form-action never fall back to default-src. +const FALLBACKS = { + "script-src-elem": ["script-src-elem", "script-src"], + "style-src-elem": ["style-src-elem", "style-src"], + "frame-src": ["frame-src", "child-src"], + "worker-src": ["worker-src", "child-src", "script-src"], +}; +function governing(directive) { + for (const name of FALLBACKS[directive] ?? [directive]) { + if (directives.has(name)) return { name, sources: directives.get(name) }; + } + if (directive === "base-uri" || directive === "form-action") return null; + return { name: "default-src", sources: directives.get("default-src") ?? null }; +} + +// Checks one reference from `source` (a dist-relative file) against the +// directive that governs it. A null directive checks the origin alone. +function checkReference(source, directive, reference) { + const ref = reference.trim(); + if (!ref) return; + const where = `${source}: ${ref.length > 80 ? `${ref.slice(0, 77)}...` : ref}`; + let url; + try { + url = new URL(ref, `${origin}/${source}`); + } catch { + fail(`${where} is not a URL`); + return; + } + if (url.protocol === "javascript:") { + fail(`${where} is a javascript: URL, which runs as inline script`); + return; + } + const rule = directive ? governing(directive) : null; + const sources = rule?.sources; + if (url.protocol === "data:") { + if (sources && !sources.includes("data:")) { + fail(`${where} is a data: URL, which ${rule.name} (${sources.join(" ")}) refuses`); + } + return; + } + if (url.origin !== origin) { + fail(`${where} is on ${url.origin}; this page loads from its own origin only`); + return; + } + if (sources && !sources.includes("'self'")) { + fail(`${where} is refused by ${rule.name} (${sources.join(" ")}), which does not grant 'self'`); + } +} + +// ---- HTML ------------------------------------------------------------------- + +const ATTRIBUTE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; +const TAG = /<([a-zA-Z][\w:-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g; + +function attributes(text) { + const out = new Map(); + for (const [, name, double, single, bare] of (text ?? "").matchAll(ATTRIBUTE)) { + const value = double ?? single ?? bare ?? ""; + out.set(name.toLowerCase(), value.replaceAll("&", "&")); + } + return out; +} + +// The directive a is fetched under, by its rel and, for a preload, its +// `as`. A rel that loads nothing is still held to the page's own origin. +const PRELOAD_AS = { + script: "script-src-elem", + style: "style-src-elem", + image: "img-src", + font: "font-src", + fetch: "connect-src", + audio: "media-src", + video: "media-src", + track: "media-src", + worker: "worker-src", +}; +function linkDirective(attrs) { + const rels = (attrs.get("rel") ?? "").toLowerCase().split(/\s+/); + if (rels.includes("stylesheet")) return "style-src-elem"; + if (rels.includes("modulepreload")) return "script-src-elem"; + if (rels.includes("manifest")) return "manifest-src"; + if (rels.some((rel) => rel.endsWith("icon"))) return "img-src"; + if (rels.includes("preload")) return PRELOAD_AS[(attrs.get("as") ?? "").toLowerCase()] ?? "default-src"; + return null; +} + +// Which attributes of which elements load a resource, and under what. +const LOADS = { + script: [["src", "script-src-elem"]], + img: [["src", "img-src"], ["srcset", "img-src"]], + video: [["src", "media-src"], ["poster", "img-src"]], + audio: [["src", "media-src"]], + track: [["src", "media-src"]], + iframe: [["src", "frame-src"]], + frame: [["src", "frame-src"]], + object: [["data", "object-src"]], + embed: [["src", "object-src"]], + form: [["action", "form-action"]], + button: [["formaction", "form-action"]], + input: [["formaction", "form-action"], ["src", "img-src"]], + base: [["href", "base-uri"]], +}; + +function srcsetUrls(value) { + return value.split(",").map((candidate) => candidate.trim().split(/\s+/)[0]); +} + +function checkHtml(path) { + const source = relative(distDir, path); + let html = readFileSync(path, "utf8").replace(//g, ""); + + // Script and style bodies first, then blanked, so markup-shaped text inside + // them is not read as markup. + html = html.replace(/<(script|style)\b([^>]*)>([\s\S]*?)<\/\1\s*>/gi, (whole, tag, attrText, body) => { + const attrs = attributes(attrText); + if (tag.toLowerCase() === "style") { + fail(`${source}: inline