Files
movieloop-frontend/scripts/csp-inline-script-hashes.mjs
TehRiehlDeal da32e49b0b
frontend-ci / lint (push) Successful in 24s
frontend-ci / test (push) Successful in 32s
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / test (pull_request) Successful in 34s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 13s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 26s
frontend-ci / build (pull_request) Successful in 48s
frontend-ci / push (pull_request) Skipped
fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
Enabling push in production failed with "Validation failed": the
browser's PushSubscription.toJSON() carries expirationTime, and it was
sent verbatim to a schema that is strict about unknown fields. The
client now sends exactly endpoint + keys. The test doubles had been
shaped like the schema rather than like a browser, which is why 1,112
tests said nothing.

The CSP hash for index.html's inline theme script was hardcoded in the
container's start-up script behind a comment asking for it to be
regenerated by hand, and claiming a test asserted it; none did. The
bytes are what the browser hashes, so an edit or a checkout with
different line endings would have silently blocked the page's own
script. The hash is now emitted by `npm run build` from the index.html
that is actually shipped, copied into the image, and read at start; the
container refuses to start without it. Verified against the built
image: header hash equals served-script hash.

For the record: a CSP console message in production is what led here,
and the served page turned out to be correct - that message was a
browser extension's injected script, which the policy is right to
refuse.
2026-09-12 10:22:14 -07:00

70 lines
2.8 KiB
JavaScript

// Emits the CSP `script-src` hash tokens for every inline script in the built
// index.html, so the container's Content-Security-Policy can allow exactly
// those scripts and nothing else.
//
// The theme bootstrap script is inline on purpose: it runs before first paint
// so the page does not flash the wrong theme, and moving it to a file would
// mean either a blocking request or the flash coming back. Allowing it by
// hash keeps 'unsafe-inline' out of the policy, which is the whole point of
// having one.
//
// The hash used to be hardcoded in docker/40-render-config.sh, with a comment
// saying it had to be regenerated by hand whenever the script changed and that
// a test asserted it. No test did. Any edit to the script - or a checkout with
// different line endings, since the bytes are what is hashed - would have
// silently blocked the page's own theme script, with nothing but a browser
// console message to show for it. Computing it here, from the bytes that are
// actually shipped, removes every way it could drift.
//
// Usage: node scripts/csp-inline-script-hashes.mjs [distDir] [outFile]
// default: dist -> build/csp-inline-script-hashes
// Output: one line, e.g. 'sha256-…' 'sha256-…', ready to splice into script-src.
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* The content of every `<script>` element with no `src`, exactly as the
* browser will hash it: the text between the tags, untrimmed.
*/
export function inlineScripts(html) {
const out = [];
const re = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
let m;
while ((m = re.exec(html)) !== null) {
const attrs = m[1];
if (/\bsrc\s*=/i.test(attrs)) continue;
out.push(m[2]);
}
return out;
}
export function cspHashToken(source) {
return `'sha256-${createHash('sha256').update(source, 'utf8').digest('base64')}'`;
}
export function cspInlineScriptHashes(html) {
return inlineScripts(html).map(cspHashToken).join(' ');
}
function main(argv) {
const distDir = resolve(argv[0] ?? 'dist');
const outFile = resolve(argv[1] ?? 'build/csp-inline-script-hashes');
const html = readFileSync(resolve(distDir, 'index.html'), 'utf8');
const tokens = cspInlineScriptHashes(html);
if (tokens.length === 0) {
// Not an error - a page with no inline scripts needs no hashes - but the
// theme script is expected, so say so where the build log is read.
console.warn('[csp] no inline scripts found in index.html');
}
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, tokens + '\n');
console.log(`[csp] inline script hashes -> ${outFile}: ${tokens || '(none)'}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2));
}