commit c9f14c3cae1408872b1307f747e09b8fa087893c
parent 8f84de47ecb40d0fa19df3087342a2a481aab59e
Author: $: DAΞMON <zer0sec.xp@icloud.com>
Date: Wed, 30 Sep 2026 05:56:42 +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 in Base.astro and two small modules Astro
inlines. 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. Astro rewrites the
inlined modules on every build, which is why the hashes are computed
rather than written by hand.
A side effect worth knowing: one imported HackTricks sheet renders XSS
demo payloads as real onerror attributes. Those are now blocked too.
Also takes npm audit's non-breaking fixes (js-yaml, svgo, devalue).
The remaining advisories need Astro 7, which is a separate change.
Tested: npm test (build + 4/4). Under wrangler dev with the built dist,
the theme bootstrap ran, search through the modal returned results, a
navigation ran the iris, the PDF page framed its PDF, all with zero CSP
violations on the console.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 94 insertions(+), 30 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -2963,16 +2963,16 @@
}
},
"node_modules/css-select": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
- "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz",
+ "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
- "css-what": "^6.1.0",
- "domhandler": "^5.0.2",
- "domutils": "^3.0.1",
- "nth-check": "^2.0.1"
+ "css-what": "^7.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "nth-check": "^2.1.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
@@ -2992,9 +2992,9 @@
}
},
"node_modules/css-what": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
- "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz",
+ "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
@@ -3121,9 +3121,9 @@
}
},
"node_modules/devalue": {
- "version": "5.9.0",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz",
- "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==",
+ "version": "5.9.4",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
+ "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"license": "MIT"
},
"node_modules/devlop": {
@@ -3963,9 +3963,9 @@
}
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
@@ -6017,18 +6017,18 @@
}
},
"node_modules/svgo": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz",
- "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
+ "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==",
"license": "MIT",
"dependencies": {
"commander": "^11.1.0",
- "css-select": "^5.1.0",
+ "css-select": "^6.0.0",
"css-tree": "^3.0.1",
- "css-what": "^6.1.0",
+ "css-what": "^7.0.0",
"csso": "^5.0.5",
"picocolors": "^1.1.1",
- "sax": "^1.5.0"
+ "sax": "1.6.1"
},
"bin": {
"svgo": "bin/svgo.js"
diff --git a/package.json b/package.json
@@ -3,10 +3,10 @@
"type": "module",
"version": "1.0.0",
"private": true,
- "description": "DÆMON//SEC — a curated vault of IT & cybersecurity cheatsheets",
+ "description": "D\u00c6MON//SEC \u2014 a curated vault of IT & cybersecurity cheatsheets",
"scripts": {
"dev": "astro dev",
- "build": "astro build && pagefind --site dist && npm run pagefind:public",
+ "build": "astro build && pagefind --site dist && npm run pagefind:public && node scripts/csp-hashes.mjs",
"test": "astro build && node --test",
"preview": "astro preview",
"pagefind:public": "rm -rf public/pagefind && cp -R dist/pagefind public/pagefind",
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, and the modules
+# 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.
# img-src is open to https: because the imported sheets hotlink figures from
# dozens of hosts; frame-src 'self' is for the PDF viewer on the JJ guide.
/*
- 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: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'self'; frame-ancestors 'none'; 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: https:; connect-src 'self'; font-src 'self' data:; object-src 'none'; media-src 'self'; frame-src 'self'; frame-ancestors 'none'; worker-src 'self'; upgrade-insecure-requests
X-Frame-Options: DENY
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 in Base.astro, and the small modules Astro inlines) are
+// allowed by SHA-256 hash instead, which a browser accepts only for the
+// exact text. Astro rewrites the inlined modules on every build, so the
+// hashes are computed from the built HTML rather than written 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})`);