All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
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>
264 lines
11 KiB
JavaScript
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);
|
|
}
|