/** * Builds a Content-Security-Policy string with the hard OWASP/Lighthouse-required * directives baked in, and opt-in sources for common third parties. Solves the * real risk of hand-writing raw CSP per project and forgetting `base-uri 'self'` * or `object-src 'none'`. * * CSP still lives in the project (it lists the project's own domains), but this * helper standardizes the skeleton so every project's CSP has the same hardened * base — you only flip flags for what the project actually loads. * * Returns { key, value } ready for buildSecurityHeaders `additional`: * * import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit' * const csp = buildCsp({ * mode: 'report-only', // start here; switch to 'enforce' when clean * r2Url: process.env.R2_PUBLIC_URL, * turnstile: true, analytics: true, * }) * const headers = buildSecurityHeaders({ hsts: prod, additional: [csp] }) * * Deploy CSP carefully: start with mode:'report-only', check the console for * violations across the whole site (forms/Turnstile, gallery/R2, embeds), add * missing sources via `extra`, THEN switch to 'enforce'. See docs/security.md. */ export function buildCsp(args = {}) { const { analytics, extra = {}, googleMaps, mode = 'enforce', r2Url, turnstile, youtube } = args; const src = { 'default-src': [ "'self'" ], // 'unsafe-inline' is hard to avoid with Next/analytics; 'unsafe-eval' is NOT // added by default (weakens CSP) — add via extra only if a library needs it. 'connect-src': [ "'self'" ], 'font-src': [ "'self'", 'https://fonts.gstatic.com', 'data:' ], 'form-action': [ "'self'" ], 'frame-src': [], 'img-src': [ "'self'", 'data:', 'blob:' ], 'script-src': [ "'self'", "'unsafe-inline'" ], 'style-src': [ "'self'", "'unsafe-inline'", 'https://fonts.googleapis.com' ], // HARD defaults (OWASP/Lighthouse) — always on, no reason to omit: 'base-uri': [ "'self'" ], 'frame-ancestors': [ "'none'" ], 'object-src': [ "'none'" ] }; if (r2Url) { src['img-src'].push(r2Url); } if (turnstile) { src['script-src'].push('https://challenges.cloudflare.com'); src['frame-src'].push('https://challenges.cloudflare.com'); src['connect-src'].push('https://challenges.cloudflare.com'); } if (analytics) { src['script-src'].push('https://www.googletagmanager.com'); src['connect-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com'); src['img-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com'); } if (youtube) { src['frame-src'].push('https://www.youtube.com', 'https://www.youtube-nocookie.com'); } if (googleMaps) { src['frame-src'].push('https://www.google.com', 'https://maps.google.com'); src['script-src'].push('https://maps.googleapis.com'); src['img-src'].push('https://maps.gstatic.com', 'https://*.googleapis.com'); } // Merge caller extras. for (const [dir, values] of Object.entries(extra)){ if (values && values.length) { src[dir] = [ ...src[dir] ?? [], ...values ]; } } const value = Object.entries(src).filter(([, values])=>values.length > 0).map(([dir, values])=>`${dir} ${values.join(' ')}`).join('; '); const key = mode === 'report-only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; return { key, value }; } //# sourceMappingURL=buildCsp.js.map