commit f53d7a32a48a392fbdcfbb449deef1d0ae3ed181
parent c83e0f33c1d93ecd92a0acab980b27ed3bcda84b
Author: $: DAΞMON <zer0sec.xp@icloud.com>
Date: Wed, 30 Sep 2026 05:56:43 +0100
Allow inline scripts by hash instead of 'unsafe-inline'
The CSP admitted any inline script. The site carries four: the theme
and page-iris bootstraps and the two Vercel stubs in Base.astro.
scripts/csp-hashes.mjs now runs last in the build, hashes every inline
script in dist and writes the hashes into dist/_headers in place of
'unsafe-inline', so the policy admits those exact scripts and nothing
else. public/_headers is the template; computed rather than written by
hand so a module Astro chooses to inline is covered automatically.
Also takes npm audit's non-breaking fixes (undici, fast-uri). The
remaining advisories need Astro 7, which is a separate change.
Tested: npm run check (0 errors), npm test (12/12). Under wrangler dev
with the built dist, the theme bootstrap ran, search through the modal
returned results, a navigation ran the iris, all with zero CSP
violations on the console.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 78 insertions(+), 14 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -4229,9 +4229,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
- "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
@@ -7018,9 +7018,9 @@
"license": "MIT"
},
"node_modules/undici": {
- "version": "8.10.1",
- "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.1.tgz",
- "integrity": "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==",
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
+ "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
diff --git a/package.json b/package.json
@@ -3,7 +3,7 @@
"type": "module",
"version": "1.0.0",
"private": true,
- "description": "DÆMONBins — the Off-the-Land Almanac: a merged, filterable GTFOBins × LOLBAS × WADComs catalog.",
+ "description": "D\u00c6MONBins \u2014 the Off-the-Land Almanac: a merged, filterable GTFOBins \u00d7 LOLBAS \u00d7 WADComs catalog.",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
@@ -17,7 +17,7 @@
"validate:data": "node scripts/validate-data.mjs",
"check": "astro check",
"test": "node --test \"test/**/*.test.mjs\"",
- "build": "npm run build:index && npm run data:public && astro build && pagefind --site dist && npm run pagefind:public",
+ "build": "npm run build:index && npm run data:public && astro build && pagefind --site dist && npm run pagefind:public && node scripts/csp-hashes.mjs",
"preview": "astro preview",
"pagefind:public": "rm -rf public/pagefind && cp -R dist/pagefind public/pagefind",
"astro": "astro"
diff --git a/public/_headers b/public/_headers
@@ -3,15 +3,21 @@
# dist/ verbatim, so this is where it is authored. Format and limits:
# developers.cloudflare.com/workers/static-assets/headers/
#
-# Mirrors daemon-sec's staticSecurityHeaders() with three differences
-# this site needs: 'wasm-unsafe-eval' and a same-origin worker for the
-# Pagefind search index, 'unsafe-inline' scripts for the theme and page
-# iris bootstraps in Base.astro, and data: fonts for the icon face inlined
-# in the CSS. No script, style or font is loaded from another origin.
+# Mirrors daemon-sec's staticSecurityHeaders() with the differences this
+# site needs: 'wasm-unsafe-eval' and a same-origin worker for the Pagefind
+# search index, and data: fonts for the icon face inlined in the CSS. No
+# script, style or font is loaded from another origin.
+#
+# Inline scripts (the theme and page-iris bootstraps, the Vercel stubs, and
+# any module Astro inlines) are allowed by hash, not by 'unsafe-inline'.
+# The placeholder below is filled by scripts/csp-hashes.mjs at the end of
+# `npm run build`, so this file is a template: the served copy is
+# dist/_headers. Styles keep 'unsafe-inline' because Astro emits style
+# attributes, which no hash can cover.
# Nothing on this site embeds another origin, so img-src and frame-src stay
# closed. frame-ancestors 'self' matches vercel.json, the Vercel-side twin.
/*
- Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; frame-ancestors 'self'; worker-src 'self'; upgrade-insecure-requests
+ Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; script-src 'self' __INLINE_SCRIPT_HASHES__ 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'none'; frame-ancestors 'self'; worker-src 'self'; upgrade-insecure-requests
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
diff --git a/scripts/csp-hashes.mjs b/scripts/csp-hashes.mjs
@@ -0,0 +1,58 @@
+// Fills the inline-script hashes into dist/_headers after a build.
+//
+// The Content-Security-Policy in public/_headers allows no 'unsafe-inline'
+// for scripts. The inline scripts the site does carry (the theme and page
+// iris bootstraps and the Vercel stubs in Base.astro, and any module Astro
+// inlines) are allowed by SHA-256 hash instead, which a browser accepts
+// only for the exact text. Astro rewrites inlined modules on every build,
+// so the hashes are computed from the built HTML rather than by hand.
+//
+// Runs last in `npm run build`. Anything that adds an inline <script> to a
+// page is covered automatically; an inline script that is NOT in dist at
+// build time (injected at runtime) will be refused by the browser.
+import { createHash } from 'node:crypto';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { readdirSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+const DIST = 'dist';
+const HEADERS = join(DIST, '_headers');
+const TOKEN = '__INLINE_SCRIPT_HASHES__';
+
+function* htmlFiles(dir) {
+ for (const name of readdirSync(dir)) {
+ const p = join(dir, name);
+ if (statSync(p).isDirectory()) yield* htmlFiles(p);
+ else if (name.endsWith('.html')) yield p;
+ }
+}
+
+// A <script> with no src and a JavaScript type. JSON blocks (JSON-LD) are
+// data the browser never executes, so they need no hash.
+const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
+const isData = (attrs) => /\btype\s*=\s*["']?(application\/(ld\+)?json|text\/(template|x-|plain))/i.test(attrs);
+
+const hashes = new Set();
+let files = 0;
+for (const file of htmlFiles(DIST)) {
+ files++;
+ const html = readFileSync(file, 'utf8');
+ for (const [, attrs, body] of html.matchAll(SCRIPT)) {
+ if (/\bsrc\s*=/i.test(attrs) || isData(attrs)) continue;
+ hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`);
+ }
+}
+
+const headers = readFileSync(HEADERS, 'utf8');
+if (!headers.includes(TOKEN)) {
+ console.error(`csp-hashes: ${HEADERS} has no ${TOKEN} placeholder`);
+ process.exit(1);
+}
+const out = headers.replace(TOKEN, [...hashes].join(' '));
+const longest = Math.max(...out.split('\n').map((l) => l.length));
+if (longest > 2000) {
+ console.error(`csp-hashes: a _headers line is ${longest} chars; Cloudflare's limit is 2000`);
+ process.exit(1);
+}
+writeFileSync(HEADERS, out);
+console.log(`csp-hashes: ${hashes.size} inline script hash(es) from ${files} pages -> ${HEADERS} (longest line ${longest})`);