mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 12:15:02 +00:00
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.
This commit is contained in:
1 parent
a60ea08f37
commit
7c893aa3b5
6 files changed
+80
-6
No files matched your search
@@ -47,7 +47,8 @@
|
|||||||
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
|
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
|
||||||
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. codeserver HMR config (port, allowedHosts, hmr). |
|
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. codeserver HMR config (port, allowedHosts, hmr). |
|
||||||
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch, verify:build. |
|
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch, verify:build. |
|
||||||
| `scripts/verify-build-inline-scripts.mjs` | Post-build CSP guard. Counts inline `<script>` tags in `build/index.html` and fails if > EXPECTED_INLINE (1). Catches future SvelteKit upgrades that add inline blocks the CSP `'unsafe-inline'` relaxation isn't calibrated for. |
|
| `scripts/verify-build-inline-scripts.mjs` | Post-build CSP guard. Counts inline `<script>` tags in `build/index.html` (fails if > EXPECTED_INLINE) and asserts `build/_headers` `script-src` has no `'unsafe-inline'`. Catches future SvelteKit upgrades that ship a new inline block the CSP isn't calibrated for. |
|
||||||
|
| `scripts/inject-csp-hashes.mjs` | Postbuild step chained into `npm run build` and `build:gh`. Reads inline `<script>` bodies from `build/index.html`, computes SHA-256, and rewrites `build/_headers` `script-src 'self' 'unsafe-inline'` → `script-src 'self' 'sha256-…'`. Hash regenerates per build; `_headers` is treated as a build artifact. |
|
||||||
| `.github/workflows/verify-build.yml` | CI: on push/PR to main runs `npm test && npm run build && npm run verify:build` to enforce the inline-script guard above. |
|
| `.github/workflows/verify-build.yml` | CI: on push/PR to main runs `npm test && npm run build && npm run verify:build` to enforce the inline-script guard above. |
|
||||||
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
|
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
|
||||||
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
|
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
|
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build && node scripts/inject-csp-hashes.mjs",
|
||||||
"build:gh": "BUILD_PROFILE=gh vite build",
|
"build:gh": "BUILD_PROFILE=gh vite build && node scripts/inject-csp-hashes.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
name: Strict CSP via hashed inline script
|
name: Strict CSP via hashed inline script
|
||||||
phase: 7
|
phase: 7
|
||||||
status: todo
|
status: completed
|
||||||
priority: medium
|
priority: medium
|
||||||
effort: 1.5h
|
effort: 1.5h
|
||||||
|
completed: 2026-04-28
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 7 — Strict CSP via hashed inline script
|
# Phase 7 — Strict CSP via hashed inline script
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ YAGNI — parking-lot features and upstream-blocked items skipped.
|
|||||||
| 4 | Settings modal sticky on small screens ✅ | `phase-04-settings-modal-sticky.md` |
|
| 4 | Settings modal sticky on small screens ✅ | `phase-04-settings-modal-sticky.md` |
|
||||||
| 5 | Per-row "Chờ" indicator ✅ | `phase-05-cho-row-indicator.md` |
|
| 5 | Per-row "Chờ" indicator ✅ | `phase-05-cho-row-indicator.md` |
|
||||||
| 6 | Confetti polish (threshold + variety) ✅ | `phase-06-confetti-polish.md` |
|
| 6 | Confetti polish (threshold + variety) ✅ | `phase-06-confetti-polish.md` |
|
||||||
| 7 | Strict CSP via hashed inline script | `phase-07-strict-csp-hashed.md` |
|
| 7 | Strict CSP via hashed inline script ✅ | `phase-07-strict-csp-hashed.md` |
|
||||||
| 8 | Audio cache LRU rule | `phase-08-audio-cache-lru.md` |
|
| 8 | Audio cache LRU rule | `phase-08-audio-cache-lru.md` |
|
||||||
| 9 | PWA install verification checklist | `phase-09-pwa-verify-install.md` |
|
| 9 | PWA install verification checklist | `phase-09-pwa-verify-install.md` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/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}.`,
|
||||||
|
);
|
||||||
@@ -13,6 +13,7 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const EXPECTED_INLINE = 1;
|
const EXPECTED_INLINE = 1;
|
||||||
const HTML_PATH = "build/index.html";
|
const HTML_PATH = "build/index.html";
|
||||||
|
const HEADERS_PATH = "build/_headers";
|
||||||
|
|
||||||
let html;
|
let html;
|
||||||
try {
|
try {
|
||||||
@@ -40,4 +41,26 @@ if (inline < EXPECTED_INLINE) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`verify-build: ${inline} inline <script> tag(s) — OK.`);
|
// Post-Phase 7: script-src must NOT contain 'unsafe-inline' anymore —
|
||||||
|
// `inject-csp-hashes` should have replaced it with sha256 hashes.
|
||||||
|
let headers;
|
||||||
|
try {
|
||||||
|
headers = readFileSync(HEADERS_PATH, "utf8");
|
||||||
|
} catch {
|
||||||
|
console.warn(`verify-build: ${HEADERS_PATH} not found — skipping CSP check.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptSrcLine = headers
|
||||||
|
.split("\n")
|
||||||
|
.find((l) => /script-src\b/.test(l) && /Content-Security-Policy/i.test(l));
|
||||||
|
// Single-line policy: Content-Security-Policy: ... script-src 'self' …
|
||||||
|
if (scriptSrcLine && /script-src[^;]*'unsafe-inline'/.test(scriptSrcLine)) {
|
||||||
|
console.error(
|
||||||
|
`verify-build: ${HEADERS_PATH} script-src still contains 'unsafe-inline'. ` +
|
||||||
|
`inject-csp-hashes.mjs should have replaced it with SHA-256 hash(es).`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`verify-build: ${inline} inline <script> tag(s), CSP hashed — OK.`);
|
||||||
Reference in new issue
Block a user