Files
runicgateway.com/scripts/checkCsp.mjs
wtclaude e71ff4acd4
All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
feat(polish): phase 10 — search, accessibility, SEO and a real CSP
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.

D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.

D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:

  * Astro does not hash <script is:inline>, and Starlight ships six per docs
    page, so the first build with CSP on had a strict header and a dead theme
    switcher. The hashes are now generated into src/config/cspHashes.mjs and
    checkCsp.mjs verifies every inline block against its own page's policy.
  * Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
    hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
    script-src is untouched.
  * @astrojs/node matched a request to a policy with pathname.includes(), a
    substring test: /modules/ was served /docs/modules/building-a-module's
    policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
    the same _headers.json and matches by equality; test/headers.test.mjs starts
    the server and reads the responses, because nothing that reads dist/ can see
    this.

D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.

checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.

npm run verify is green: fourteen steps, both test suites, all eleven checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 14:14:53 -05:00

264 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* checkCsp.mjs — PLAN.md §6 and D48, added in phase 10.
*
* §6 promises "a strict CSP with no external origins". D48 decided that promise should be
* a real response header sent by the container itself, not a `<meta>` (which ignores
* `frame-ancestors`) and not advice in an operator's proxy config (which lives outside the
* artifact we ship and test). `astro.config.mjs` sets it up; this checks it arrived.
*
* node scripts/checkCsp.mjs # verify the built output
* node scripts/checkCsp.mjs --write # rewrite src/config/cspHashes.mjs from the build
* node scripts/checkCsp.mjs --reset # empty it, so the next harvest starts from nothing
*
* Three things are checked, and each one has already been wrong once:
*
* 1. **Every built route has a policy.** `staticHeaders` writes `dist/_headers.json`; a
* route missing from it is a page served with no CSP at all, which is the failure mode
* nobody notices because the page looks perfect.
*
* 2. **Every inline script and style is covered by its page's own policy.** This is the
* real check. Astro does not hash `<script is:inline>`, and Starlight ships six of
* them per documentation page — so the first build with CSP on had a strict, correct
* header and a dead theme switcher. Hashing is verified per page against that page's
* header, not against a global list, because that is what the browser does.
*
* 3. **The directives §6 actually promised are present.** A policy that lost
* `frame-ancestors` in a refactor still passes checks 1 and 2 while no longer stopping
* anything.
*
* `--write` harvests the hashes from check 2 into `src/config/cspHashes.mjs`, which
* `astro.config.mjs` feeds back into the next build. So the sequence is reset → build →
* write → build → verify, which is what `npm run csp:hashes` runs. It resets first because
* harvesting only ever collects what the build did NOT cover — see `--reset` below.
*
* No token and no network: everything read here is in `dist/`.
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const headersFile = path.join(root, 'dist', '_headers.json');
const clientDir = path.join(root, 'dist', 'client');
const hashesFile = path.join(root, 'src', 'config', 'cspHashes.mjs');
const write = process.argv.includes('--write');
const reset = process.argv.includes('--reset');
const failures = [];
const fail = (what, detail) => failures.push({ what, detail });
/**
* Rewrites the two exported arrays in `src/config/cspHashes.mjs`, leaving every comment and
* the JSDoc types above them untouched.
*/
const writeHashes = (script, style) => {
const source = fs.readFileSync(hashesFile, 'utf8');
const list = (hashes) =>
hashes.size === 0 ? '[]' : `[\n${[...hashes].sort().map((h) => ` '${h}',`).join('\n')}\n]`;
fs.writeFileSync(
hashesFile,
source
.replace(
/export const inlineScriptHashes = [\s\S]*?;\n/,
`export const inlineScriptHashes = ${list(script)};\n`,
)
.replace(
/export const inlineStyleHashes = [\s\S]*?;\n/,
`export const inlineStyleHashes = ${list(style)};\n`,
),
);
};
/**
* `--reset` empties the generated file, and `npm run csp:hashes` runs it FIRST.
*
* Without it the regeneration is not idempotent, and its failure mode is the worst
* available: harvesting collects the blocks the build did not cover, so running it against
* a build that is already correct finds nothing, writes two empty arrays and produces a
* build with no hashes at all. Emptying first means the harvest always sees the same thing
* — every inline block Astro does not hash on its own — whatever state the file was in.
*/
if (reset) {
writeHashes(new Set(), new Set());
console.log('checkCsp --reset: src/config/cspHashes.mjs emptied, ready to re-harvest.');
process.exit(0);
}
// ── The build has to be there ───────────────────────────────────────────────
if (!fs.existsSync(headersFile)) {
console.error(`
checkCsp: dist/_headers.json does not exist.
That file is written by the Node adapter's \`staticHeaders\` option, so either the build
has not run (\`npm run build\`) or \`staticHeaders\` was turned off in astro.config.mjs —
in which case the CSP is a <meta> tag and \`frame-ancestors\` is being ignored (D48).
`);
process.exit(1);
}
/**
* `_headers.json` is keyed by an internal route id, so the pathname lives in the record.
* Normalised without a trailing slash: the file says `/docs/first-run`, the built page is
* at `docs/first-run/index.html`, and `build.format: 'directory'` serves it at
* `/docs/first-run/`.
*/
const byPath = new Map();
for (const record of Object.values(JSON.parse(fs.readFileSync(headersFile, 'utf8')))) {
const csp = record.headers?.find((h) => h.key.toLowerCase() === 'content-security-policy');
byPath.set(record.pathname.replace(/\/$/, '') || '/', csp?.value ?? null);
}
// ── Walk the built HTML ─────────────────────────────────────────────────────
const pages = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name === 'index.html' || entry.name.endsWith('.html')) pages.push(full);
}
};
walk(clientDir);
/**
* Inline only: anything with a `src` is a fetched file and is covered by `'self'`.
* The body is hashed exactly as written, because that is what the browser hashes — one
* byte of whitespace either side changes the digest.
*/
const INLINE_SCRIPT = /<script(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/g;
const INLINE_STYLE = /<style([^>]*)>([\s\S]*?)<\/style>/g;
/**
* `<script type="application/ld+json">` (D50) is a data block, not code: the browser never
* executes it, and CSP's script-src is not enforced against it. Demanding a hash for one
* would be wrong twice over — it would add the structured data's own text to the list of
* scripts allowed to run, and that text changes whenever a fact or the brand name does, so
* the generated hash file would churn on edits that cannot affect security.
*/
const DATA_BLOCK = /type\s*=\s*["']application\/(ld\+json|json)["']/i;
const sha256 = (body) => `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`;
const harvested = { script: new Set(), style: new Set() };
let inlineScripts = 0;
let inlineStyles = 0;
let uncovered = 0;
for (const file of pages) {
const rel = path.relative(clientDir, file).replace(/\\/g, '/');
const pathname = '/' + rel.replace(/index\.html$/, '').replace(/\.html$/, '').replace(/\/$/, '');
const csp = byPath.get(pathname === '/' ? '/' : pathname.replace(/\/$/, ''));
if (csp === undefined) {
fail(pathname, 'is a built page with no entry in dist/_headers.json — it ships with no CSP');
continue;
}
if (csp === null) {
fail(pathname, 'has an entry in dist/_headers.json but no Content-Security-Policy header');
continue;
}
const html = fs.readFileSync(file, 'utf8');
for (const [kind, re, counter] of [
['script', INLINE_SCRIPT, 'inlineScripts'],
['style', INLINE_STYLE, 'inlineStyles'],
]) {
re.lastIndex = 0;
let match;
while ((match = re.exec(html))) {
const [, attrs, body] = match;
if (kind === 'script' && DATA_BLOCK.test(attrs)) continue;
// An empty inline block needs no hash; browsers do not enforce one.
if (body.trim() === '') continue;
if (counter === 'inlineScripts') inlineScripts++;
else inlineStyles++;
const hash = sha256(body);
if (csp.includes(hash)) continue;
uncovered++;
harvested[kind].add(hash);
if (!write) {
fail(
`${pathname} (inline <${kind}>)`,
`${hash} is not in that page's policy — ${JSON.stringify(body.trim().slice(0, 60))}`,
);
}
}
}
}
// ── The directives §6 promised, on a page that has to have them ─────────────
const REQUIRED = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
];
const home = byPath.get('/');
if (!home) {
fail('/', 'the homepage has no CSP header at all');
} else {
for (const directive of REQUIRED) {
if (!home.includes(directive)) fail('/ policy', `is missing "${directive}" (PLAN.md §6)`);
}
// The point of the whole exercise: a hash and 'unsafe-inline' in the same script
// directive means browsers ignore 'unsafe-inline' — but if the hashes ever went away it
// would quietly start applying.
const scriptSrc = /script-src ([^;]*)/.exec(home)?.[1] ?? '';
if (scriptSrc.includes("'unsafe-inline'")) {
fail('/ policy', "script-src contains 'unsafe-inline' — D48 says the hashes carry this");
}
if (scriptSrc.includes("'unsafe-eval'")) {
fail('/ policy', "script-src contains 'unsafe-eval' ('wasm-unsafe-eval' is the intended one)");
}
}
// ── --write: regenerate the hash file ───────────────────────────────────────
if (write) {
writeHashes(harvested.script, harvested.style);
console.log(
`checkCsp --write: harvested ${harvested.script.size} script and ${harvested.style.size} ` +
`style hash(es) from ${pages.length} pages into src/config/cspHashes.mjs.`,
);
if (failures.length) {
console.error('\ncheckCsp --write: the build is still wrong in ways hashes cannot fix:\n');
for (const f of failures) console.error(`${f.what}\n ${f.detail}`);
process.exit(1);
}
console.log('Now rebuild so the next build embeds them (npm run csp:hashes does both).');
process.exit(0);
}
// ── Report ──────────────────────────────────────────────────────────────────
if (failures.length === 0) {
console.log(
`checkCsp: ${pages.length} pages carry a policy; ` +
`${inlineScripts} inline script(s) and ${inlineStyles} inline style(s) are all hashed.`,
);
} else {
console.error(`\ncheckCsp: ${failures.length} problem(s) with the Content-Security-Policy:\n`);
for (const f of failures) console.error(`${f.what}\n ${f.detail}`);
if (uncovered) {
console.error(`
${uncovered} inline block(s) are not covered by a hash. In a browser this is silent: the
page renders and the script simply never runs — Starlight's theme switch and mobile
sidebar are inline scripts, so this is how the documentation loses them.
If the inline block is legitimate (usually: Starlight was upgraded), run
npm run csp:hashes
which rebuilds, harvests the hashes into src/config/cspHashes.mjs and rebuilds again.
Read what changed before committing it — that file is a list of scripts allowed to run.
`);
}
process.exit(1);
}