|
| 1 | +/** |
| 2 | + * Generates a Content-Security-Policy for the built docs site. |
| 3 | + * |
| 4 | + * Docusaurus emits a small number of inline <script> tags (the color-mode/theme bootstrap and |
| 5 | + * the base-url-issue banner). Allowing them via 'unsafe-inline' defeats the purpose of a CSP, |
| 6 | + * so instead this script scans the build output, computes the sha256 hash of every executable |
| 7 | + * inline script, and produces a policy that allows exactly those scripts and nothing else. |
| 8 | + * |
| 9 | + * Run automatically as part of `npm run build`. Outputs: |
| 10 | + * 1. `build/_headers` — applied automatically when hosting on Cloudflare Pages |
| 11 | + * 2. `build/serve.json` — applies the same header when testing locally via `npx serve build` |
| 12 | + * 3. The policy printed to stdout — paste into a Cloudflare Response Header Transform Rule |
| 13 | + * if the header is managed at the zone level instead |
| 14 | + * |
| 15 | + * Hashes are derived from the build output itself, so Docusaurus upgrades or config changes |
| 16 | + * that alter the inline scripts are picked up automatically on the next build. |
| 17 | + */ |
| 18 | +import { createHash } from 'node:crypto'; |
| 19 | +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 20 | +import { join } from 'node:path'; |
| 21 | +import { fileURLToPath } from 'node:url'; |
| 22 | + |
| 23 | +const BUILD_DIR = fileURLToPath(new URL('../build', import.meta.url)); |
| 24 | + |
| 25 | +// Script types that browsers execute and CSP script-src applies to. |
| 26 | +// Anything else (e.g. application/ld+json structured data) is inert and needs no hash. |
| 27 | +const EXECUTABLE_SCRIPT_TYPES = new Set(['', 'module', 'text/javascript', 'application/javascript']); |
| 28 | + |
| 29 | +const SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi; |
| 30 | + |
| 31 | +function collectHtmlFiles(dir) { |
| 32 | + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { |
| 33 | + const fullPath = join(dir, entry.name); |
| 34 | + if (entry.isDirectory()) { |
| 35 | + return collectHtmlFiles(fullPath); |
| 36 | + } |
| 37 | + return entry.name.endsWith('.html') ? [fullPath] : []; |
| 38 | + }); |
| 39 | +} |
| 40 | + |
| 41 | +function getInlineScriptHashes() { |
| 42 | + const hashes = new Set(); |
| 43 | + for (const htmlFile of collectHtmlFiles(BUILD_DIR)) { |
| 44 | + const html = readFileSync(htmlFile, 'utf-8'); |
| 45 | + for (const [, attrs, body] of html.matchAll(SCRIPT_TAG_REGEX)) { |
| 46 | + const hasSrc = /\bsrc\s*=/i.test(attrs); |
| 47 | + const type = (attrs.match(/\btype\s*=\s*["']?([^"'\s>]*)/i)?.[1] ?? '').toLowerCase(); |
| 48 | + if (hasSrc || !EXECUTABLE_SCRIPT_TYPES.has(type) || !body.trim()) { |
| 49 | + continue; |
| 50 | + } |
| 51 | + // CSP hashes are computed over the exact script content, byte for byte |
| 52 | + hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`); |
| 53 | + } |
| 54 | + } |
| 55 | + return [...hashes].sort(); |
| 56 | +} |
| 57 | + |
| 58 | +const scriptHashes = getInlineScriptHashes(); |
| 59 | + |
| 60 | +const csp = [ |
| 61 | + `default-src 'self'`, |
| 62 | + `script-src 'self' ${scriptHashes.join(' ')}`, |
| 63 | + `style-src 'self' 'unsafe-inline'`, |
| 64 | + `img-src 'self' data:`, |
| 65 | + `font-src 'self' data:`, |
| 66 | + `connect-src 'self'`, |
| 67 | + `frame-ancestors 'none'`, |
| 68 | + `base-uri 'self'`, |
| 69 | + `form-action 'self'`, |
| 70 | + `object-src 'none'`, |
| 71 | + `upgrade-insecure-requests`, |
| 72 | +].join('; '); |
| 73 | + |
| 74 | +const headers = { |
| 75 | + 'Content-Security-Policy': csp, |
| 76 | + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()', |
| 77 | + 'Referrer-Policy': 'strict-origin-when-cross-origin', |
| 78 | + 'X-Content-Type-Options': 'nosniff', |
| 79 | +}; |
| 80 | + |
| 81 | +const headerLines = Object.entries(headers) |
| 82 | + .map(([key, value]) => ` ${key}: ${value}`) |
| 83 | + .join('\n'); |
| 84 | +writeFileSync(join(BUILD_DIR, '_headers'), `/*\n${headerLines}\n`); |
| 85 | + |
| 86 | +const serveConfig = { |
| 87 | + headers: [{ source: '**', headers: Object.entries(headers).map(([key, value]) => ({ key, value })) }], |
| 88 | +}; |
| 89 | +writeFileSync(join(BUILD_DIR, 'serve.json'), `${JSON.stringify(serveConfig, null, 2)}\n`); |
| 90 | + |
| 91 | +console.log(`Found ${scriptHashes.length} inline script hash(es):`); |
| 92 | +scriptHashes.forEach((hash) => console.log(` ${hash}`)); |
| 93 | +console.log('\nWrote build/_headers with the following policy:\n'); |
| 94 | +console.log(csp); |
0 commit comments