import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const BUILD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'build'); const scriptRe = /]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi; function* walk(dir) { for (const entry of readdirSync(dir)) { const path = join(dir, entry); if (statSync(path).isDirectory()) yield* walk(path); else if (entry.endsWith('.html')) yield path; } } // La politique complète (hors script-src) est lue depuis build/_headers, // unique source de vérité partagée avec .htaccess const headers = readFileSync(join(BUILD_DIR, '_headers'), 'utf8'); const cspLine = headers .split('\n') .map((line) => line.trim()) .find((line) => line.startsWith('Content-Security-Policy:')); if (!cspLine) throw new Error('[csp] Content-Security-Policy introuvable dans build/_headers'); const sharedPolicy = cspLine .slice('Content-Security-Policy:'.length) .trim() // frame-ancestors est ignoré (et logué) dans une balise meta : il reste porté par l'en-tête HTTP .replace(/frame-ancestors 'none';\s*/, ''); let pages = 0; for (const file of walk(BUILD_DIR)) { const html = readFileSync(file, 'utf8'); const hashes = new Set(); for (const match of html.matchAll(scriptRe)) { const content = match[1]; if (!content.trim()) continue; hashes.add(`'sha256-${createHash('sha256').update(content).digest('base64')}'`); } if (hashes.size === 0) continue; const csp = `default-src 'self'; script-src 'self' ${[...hashes].join(' ')}; ${sharedPolicy}`; const meta = ``; const anchor = html.match(//); if (!anchor) { console.warn(`[csp] pas d'ancre charset dans ${file}, page ignorée`); continue; } writeFileSync(file, html.replace(anchor[0], `${anchor[0]}\n\t\t${meta}`)); pages++; } console.log(`[csp] meta CSP complète injectée dans ${pages} page(s)`);