Files
ipal-kit/src/modules/security/buildCsp.ts
T
2026-09-19 22:32:29 +02:00

117 lines
4.7 KiB
TypeScript

export type BuildCspArgs = {
/** Google Analytics / GTM — adds googletagmanager + google-analytics. */
analytics?: boolean
/** Extra sources per directive, merged with the built-ins. */
extra?: Partial<Record<CspDirective, string[]>>
/** Google Maps embeds — adds maps.google.com / *.gstatic.com. */
googleMaps?: boolean
/** 'enforce' → Content-Security-Policy; 'report-only' → …-Report-Only header. */
mode?: 'enforce' | 'report-only'
/** Media/R2 public URL (from R2_PUBLIC_URL) — added to img-src. */
r2Url?: string
/** Cloudflare Turnstile — adds challenges.cloudflare.com to script/frame/connect. */
turnstile?: boolean
/** YouTube embeds — adds youtube to frame-src. */
youtube?: boolean
}
type CspDirective =
| 'base-uri'
| 'connect-src'
| 'default-src'
| 'font-src'
| 'form-action'
| 'frame-ancestors'
| 'frame-src'
| 'img-src'
| 'media-src'
| 'object-src'
| 'script-src'
| 'style-src'
| 'worker-src'
/**
* 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: BuildCspArgs = {}): { key: string; value: string } {
const { analytics, extra = {}, googleMaps, mode = 'enforce', r2Url, turnstile, youtube } = args
const src: Record<CspDirective, string[]> = {
'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:'],
'media-src': [], // video/audio sources — filled via extra when needed
'script-src': ["'self'", "'unsafe-inline'"],
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
'worker-src': [], // web workers — filled via extra when needed
// HARD defaults (OWASP/Lighthouse) — always on, no reason to omit:
'base-uri': ["'self'"], // block <base> hijacking
'frame-ancestors': ["'none'"], // clickjacking protection (replaces X-Frame-Options)
'object-src': ["'none'"], // block <object>/<embed> (Flash-era attack surface)
}
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) as Array<[CspDirective, string[]]>) {
if (values && values.length) {src[dir] = [...(src[dir] ?? []), ...values]}
}
const value = (Object.entries(src) as Array<[CspDirective, string[]]>)
.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 }
}