Files
loto/web/scripts/inject-csp-hashes.mjs
T
tiennm99 3fd02c14a2 ci(csp): replace 'unsafe-inline' with sha256 hash at build time
Postbuild script computes SHA-256 of every inline <script> in
build/index.html and rewrites build/_headers — replacing the
script-src 'unsafe-inline' relaxation with the matching hashes. The
hash regenerates per build (SvelteKit bootstrap embeds a per-build
registration call) so the script must run on every build; chain it
into both `npm run build` and `build:gh`.

verify-build extended to assert build/_headers script-src no longer
contains 'unsafe-inline', so the inject step's output is enforced in
CI. style-src 'unsafe-inline' stays — Svelte's `style:` directives
emit inline attributes that hashes can't cover.
2026-04-28 11:09:32 +07:00

50 lines
1.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Postbuild step: replace `'unsafe-inline'` in build/_headers script-src
* with the SHA-256 hash(es) of every inline <script> in
* build/index.html. Hash changes per build are expected (the
* SvelteKit bootstrap embeds a timestamped registration call), so
* this script must run on every build.
*
* If no inline scripts are present (future SvelteKit could go
* src-only), the script removes `'unsafe-inline'` entirely so the
* tightest possible CSP ships.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const HEADERS = "build/_headers";
const HTML = "build/index.html";
const MARKER = `script-src 'self' 'unsafe-inline'`;
const html = readFileSync(HTML, "utf8");
const inlineScripts = [
...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g),
];
const hashes = inlineScripts.map((m) => {
const body = m[1];
const digest = createHash("sha256").update(body, "utf8").digest("base64");
return `'sha256-${digest}'`;
});
const replacement =
hashes.length > 0
? `script-src 'self' ${hashes.join(" ")}`
: `script-src 'self'`;
const headers = readFileSync(HEADERS, "utf8");
if (!headers.includes(MARKER)) {
console.error(
`inject-csp-hashes: marker not found in ${HEADERS}.\nLooking for: ${MARKER}\n` +
`Either the previous build already replaced it (re-run \`npm run build\` from clean) ` +
`or static/_headers no longer contains the relaxed script-src directive.`,
);
process.exit(1);
}
writeFileSync(HEADERS, headers.replace(MARKER, replacement), "utf8");
console.log(
`inject-csp-hashes: replaced 'unsafe-inline' with ${hashes.length} hash(es) in ${HEADERS}.`,
);