daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

csp-hashes.mjs (2465B)


      1 // Fills the inline-script hashes into dist/_headers after a build.
      2 //
      3 // The Content-Security-Policy in public/_headers allows no 'unsafe-inline'
      4 // for scripts. The inline scripts the site does carry (the theme and page
      5 // iris bootstraps in Base.astro, and the small modules Astro inlines) are
      6 // allowed by SHA-256 hash instead, which a browser accepts only for the
      7 // exact text. Astro rewrites the inlined modules on every build, so the
      8 // hashes are computed from the built HTML rather than written by hand.
      9 //
     10 // Runs last in `npm run build`. Anything that adds an inline <script> to a
     11 // page is covered automatically; an inline script that is NOT in dist at
     12 // build time (injected at runtime) will be refused by the browser.
     13 import { createHash } from 'node:crypto';
     14 import { readFileSync, writeFileSync } from 'node:fs';
     15 import { readdirSync, statSync } from 'node:fs';
     16 import { join } from 'node:path';
     17 
     18 const DIST = 'dist';
     19 const HEADERS = join(DIST, '_headers');
     20 const TOKEN = '__INLINE_SCRIPT_HASHES__';
     21 
     22 function* htmlFiles(dir) {
     23   for (const name of readdirSync(dir)) {
     24     const p = join(dir, name);
     25     if (statSync(p).isDirectory()) yield* htmlFiles(p);
     26     else if (name.endsWith('.html')) yield p;
     27   }
     28 }
     29 
     30 // A <script> with no src and a JavaScript type. JSON blocks (JSON-LD) are
     31 // data the browser never executes, so they need no hash.
     32 const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
     33 const isData = (attrs) => /\btype\s*=\s*["']?(application\/(ld\+)?json|text\/(template|x-|plain))/i.test(attrs);
     34 
     35 const hashes = new Set();
     36 let files = 0;
     37 for (const file of htmlFiles(DIST)) {
     38   files++;
     39   const html = readFileSync(file, 'utf8');
     40   for (const [, attrs, body] of html.matchAll(SCRIPT)) {
     41     if (/\bsrc\s*=/i.test(attrs) || isData(attrs)) continue;
     42     hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`);
     43   }
     44 }
     45 
     46 const headers = readFileSync(HEADERS, 'utf8');
     47 if (!headers.includes(TOKEN)) {
     48   console.error(`csp-hashes: ${HEADERS} has no ${TOKEN} placeholder`);
     49   process.exit(1);
     50 }
     51 const out = headers.replace(TOKEN, [...hashes].join(' '));
     52 const longest = Math.max(...out.split('\n').map((l) => l.length));
     53 if (longest > 2000) {
     54   console.error(`csp-hashes: a _headers line is ${longest} chars; Cloudflare's limit is 2000`);
     55   process.exit(1);
     56 }
     57 writeFileSync(HEADERS, out);
     58 console.log(`csp-hashes: ${hashes.size} inline script hash(es) from ${files} pages -> ${HEADERS} (longest line ${longest})`);