csp-hashes.mjs (2467B)
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 and the Vercel stubs in Base.astro, and any module Astro 6 // inlines) are allowed by SHA-256 hash instead, which a browser accepts 7 // only for the exact text. Astro rewrites inlined modules on every build, 8 // so the hashes are computed from the built HTML rather than 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})`);