daemon-sec-lotl

DÆMONBins: GTFOBins × LOLBAS × WADComs × LOOBins in one filterable catalog
git clone https://git.daemon-sec.xyz/daemon-sec-lotl.git
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackage-lock.json | 12++++++------
Mpackage.json | 4++--
Mpublic/_headers | 18++++++++++++------
Ascripts/csp-hashes.mjs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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})`);