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
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.
70 lines
2.8 KiB
JavaScript
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));
|
|
}
|