diff --git a/dist/modules/security/buildCsp.d.ts b/dist/modules/security/buildCsp.d.ts index 812ef7d..9499a76 100644 --- a/dist/modules/security/buildCsp.d.ts +++ b/dist/modules/security/buildCsp.d.ts @@ -14,7 +14,7 @@ export type BuildCspArgs = { /** 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' | 'object-src' | 'script-src' | 'style-src'; +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 diff --git a/dist/modules/security/buildCsp.js b/dist/modules/security/buildCsp.js index c0993ee..292a43d 100644 --- a/dist/modules/security/buildCsp.js +++ b/dist/modules/security/buildCsp.js @@ -46,6 +46,7 @@ 'data:', 'blob:' ], + 'media-src': [], 'script-src': [ "'self'", "'unsafe-inline'" @@ -55,6 +56,7 @@ "'unsafe-inline'", 'https://fonts.googleapis.com' ], + 'worker-src': [], // HARD defaults (OWASP/Lighthouse) — always on, no reason to omit: 'base-uri': [ "'self'" diff --git a/dist/modules/security/buildCsp.js.map b/dist/modules/security/buildCsp.js.map index de1fa0b..2347172 100644 --- a/dist/modules/security/buildCsp.js.map +++ b/dist/modules/security/buildCsp.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/security/buildCsp.ts"],"sourcesContent":["export type BuildCspArgs = {\n /** Google Analytics / GTM — adds googletagmanager + google-analytics. */\n analytics?: boolean\n /** Extra sources per directive, merged with the built-ins. */\n extra?: Partial>\n /** Google Maps embeds — adds maps.google.com / *.gstatic.com. */\n googleMaps?: boolean\n /** 'enforce' → Content-Security-Policy; 'report-only' → …-Report-Only header. */\n mode?: 'enforce' | 'report-only'\n /** Media/R2 public URL (from R2_PUBLIC_URL) — added to img-src. */\n r2Url?: string\n /** Cloudflare Turnstile — adds challenges.cloudflare.com to script/frame/connect. */\n turnstile?: boolean\n /** YouTube embeds — adds youtube to frame-src. */\n youtube?: boolean\n}\n\ntype CspDirective =\n | 'base-uri'\n | 'connect-src'\n | 'default-src'\n | 'font-src'\n | 'form-action'\n | 'frame-ancestors'\n | 'frame-src'\n | 'img-src'\n | 'object-src'\n | 'script-src'\n | 'style-src'\n\n/**\n * Builds a Content-Security-Policy string with the hard OWASP/Lighthouse-required\n * directives baked in, and opt-in sources for common third parties. Solves the\n * real risk of hand-writing raw CSP per project and forgetting `base-uri 'self'`\n * or `object-src 'none'`.\n *\n * CSP still lives in the project (it lists the project's own domains), but this\n * helper standardizes the skeleton so every project's CSP has the same hardened\n * base — you only flip flags for what the project actually loads.\n *\n * Returns { key, value } ready for buildSecurityHeaders `additional`:\n *\n * import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'\n * const csp = buildCsp({\n * mode: 'report-only', // start here; switch to 'enforce' when clean\n * r2Url: process.env.R2_PUBLIC_URL,\n * turnstile: true, analytics: true,\n * })\n * const headers = buildSecurityHeaders({ hsts: prod, additional: [csp] })\n *\n * Deploy CSP carefully: start with mode:'report-only', check the console for\n * violations across the whole site (forms/Turnstile, gallery/R2, embeds), add\n * missing sources via `extra`, THEN switch to 'enforce'. See docs/security.md.\n */\nexport function buildCsp(args: BuildCspArgs = {}): { key: string; value: string } {\n const { analytics, extra = {}, googleMaps, mode = 'enforce', r2Url, turnstile, youtube } = args\n\n const src: Record = {\n 'default-src': [\"'self'\"],\n // 'unsafe-inline' is hard to avoid with Next/analytics; 'unsafe-eval' is NOT\n // added by default (weakens CSP) — add via extra only if a library needs it.\n 'connect-src': [\"'self'\"],\n 'font-src': [\"'self'\", 'https://fonts.gstatic.com', 'data:'],\n 'form-action': [\"'self'\"],\n 'frame-src': [],\n 'img-src': [\"'self'\", 'data:', 'blob:'],\n 'script-src': [\"'self'\", \"'unsafe-inline'\"],\n 'style-src': [\"'self'\", \"'unsafe-inline'\", 'https://fonts.googleapis.com'],\n // HARD defaults (OWASP/Lighthouse) — always on, no reason to omit:\n 'base-uri': [\"'self'\"], // block hijacking\n 'frame-ancestors': [\"'none'\"], // clickjacking protection (replaces X-Frame-Options)\n 'object-src': [\"'none'\"], // block / (Flash-era attack surface)\n }\n\n if (r2Url) {src['img-src'].push(r2Url)}\n\n if (turnstile) {\n src['script-src'].push('https://challenges.cloudflare.com')\n src['frame-src'].push('https://challenges.cloudflare.com')\n src['connect-src'].push('https://challenges.cloudflare.com')\n }\n\n if (analytics) {\n src['script-src'].push('https://www.googletagmanager.com')\n src['connect-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com')\n src['img-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com')\n }\n\n if (youtube) {\n src['frame-src'].push('https://www.youtube.com', 'https://www.youtube-nocookie.com')\n }\n\n if (googleMaps) {\n src['frame-src'].push('https://www.google.com', 'https://maps.google.com')\n src['script-src'].push('https://maps.googleapis.com')\n src['img-src'].push('https://maps.gstatic.com', 'https://*.googleapis.com')\n }\n\n // Merge caller extras.\n for (const [dir, values] of Object.entries(extra) as Array<[CspDirective, string[]]>) {\n if (values && values.length) {src[dir] = [...(src[dir] ?? []), ...values]}\n }\n\n const value = (Object.entries(src) as Array<[CspDirective, string[]]>)\n .filter(([, values]) => values.length > 0)\n .map(([dir, values]) => `${dir} ${values.join(' ')}`)\n .join('; ')\n\n const key =\n mode === 'report-only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'\n return { key, value }\n}\n"],"names":["buildCsp","args","analytics","extra","googleMaps","mode","r2Url","turnstile","youtube","src","push","dir","values","Object","entries","length","value","filter","map","join","key"],"mappings":"AA8BA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,SAASC,OAAqB,CAAC,CAAC;IAC9C,MAAM,EAAEC,SAAS,EAAEC,QAAQ,CAAC,CAAC,EAAEC,UAAU,EAAEC,OAAO,SAAS,EAAEC,KAAK,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGP;IAE3F,MAAMQ,MAAsC;QAC1C,eAAe;YAAC;SAAS;QACzB,6EAA6E;QAC7E,6EAA6E;QAC7E,eAAe;YAAC;SAAS;QACzB,YAAY;YAAC;YAAU;YAA6B;SAAQ;QAC5D,eAAe;YAAC;SAAS;QACzB,aAAa,EAAE;QACf,WAAW;YAAC;YAAU;YAAS;SAAQ;QACvC,cAAc;YAAC;YAAU;SAAkB;QAC3C,aAAa;YAAC;YAAU;YAAmB;SAA+B;QAC1E,mEAAmE;QACnE,YAAY;YAAC;SAAS;QACtB,mBAAmB;YAAC;SAAS;QAC7B,cAAc;YAAC;SAAS;IAC1B;IAEA,IAAIH,OAAO;QAACG,GAAG,CAAC,UAAU,CAACC,IAAI,CAACJ;IAAM;IAEtC,IAAIC,WAAW;QACbE,GAAG,CAAC,aAAa,CAACC,IAAI,CAAC;QACvBD,GAAG,CAAC,YAAY,CAACC,IAAI,CAAC;QACtBD,GAAG,CAAC,cAAc,CAACC,IAAI,CAAC;IAC1B;IAEA,IAAIR,WAAW;QACbO,GAAG,CAAC,aAAa,CAACC,IAAI,CAAC;QACvBD,GAAG,CAAC,cAAc,CAACC,IAAI,CAAC,oCAAoC;QAC5DD,GAAG,CAAC,UAAU,CAACC,IAAI,CAAC,oCAAoC;IAC1D;IAEA,IAAIF,SAAS;QACXC,GAAG,CAAC,YAAY,CAACC,IAAI,CAAC,2BAA2B;IACnD;IAEA,IAAIN,YAAY;QACdK,GAAG,CAAC,YAAY,CAACC,IAAI,CAAC,0BAA0B;QAChDD,GAAG,CAAC,aAAa,CAACC,IAAI,CAAC;QACvBD,GAAG,CAAC,UAAU,CAACC,IAAI,CAAC,4BAA4B;IAClD;IAEA,uBAAuB;IACvB,KAAK,MAAM,CAACC,KAAKC,OAAO,IAAIC,OAAOC,OAAO,CAACX,OAA2C;QACpF,IAAIS,UAAUA,OAAOG,MAAM,EAAE;YAACN,GAAG,CAACE,IAAI,GAAG;mBAAKF,GAAG,CAACE,IAAI,IAAI,EAAE;mBAAMC;aAAO;QAAA;IAC3E;IAEA,MAAMI,QAAQ,AAACH,OAAOC,OAAO,CAACL,KAC3BQ,MAAM,CAAC,CAAC,GAAGL,OAAO,GAAKA,OAAOG,MAAM,GAAG,GACvCG,GAAG,CAAC,CAAC,CAACP,KAAKC,OAAO,GAAK,GAAGD,IAAI,CAAC,EAAEC,OAAOO,IAAI,CAAC,MAAM,EACnDA,IAAI,CAAC;IAER,MAAMC,MACJf,SAAS,gBAAgB,wCAAwC;IACnE,OAAO;QAAEe;QAAKJ;IAAM;AACtB"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/security/buildCsp.ts"],"sourcesContent":["export type BuildCspArgs = {\n /** Google Analytics / GTM — adds googletagmanager + google-analytics. */\n analytics?: boolean\n /** Extra sources per directive, merged with the built-ins. */\n extra?: Partial>\n /** Google Maps embeds — adds maps.google.com / *.gstatic.com. */\n googleMaps?: boolean\n /** 'enforce' → Content-Security-Policy; 'report-only' → …-Report-Only header. */\n mode?: 'enforce' | 'report-only'\n /** Media/R2 public URL (from R2_PUBLIC_URL) — added to img-src. */\n r2Url?: string\n /** Cloudflare Turnstile — adds challenges.cloudflare.com to script/frame/connect. */\n turnstile?: boolean\n /** YouTube embeds — adds youtube to frame-src. */\n youtube?: boolean\n}\n\ntype CspDirective =\n | 'base-uri'\n | 'connect-src'\n | 'default-src'\n | 'font-src'\n | 'form-action'\n | 'frame-ancestors'\n | 'frame-src'\n | 'img-src'\n | 'media-src'\n | 'object-src'\n | 'script-src'\n | 'style-src'\n | 'worker-src'\n\n/**\n * Builds a Content-Security-Policy string with the hard OWASP/Lighthouse-required\n * directives baked in, and opt-in sources for common third parties. Solves the\n * real risk of hand-writing raw CSP per project and forgetting `base-uri 'self'`\n * or `object-src 'none'`.\n *\n * CSP still lives in the project (it lists the project's own domains), but this\n * helper standardizes the skeleton so every project's CSP has the same hardened\n * base — you only flip flags for what the project actually loads.\n *\n * Returns { key, value } ready for buildSecurityHeaders `additional`:\n *\n * import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'\n * const csp = buildCsp({\n * mode: 'report-only', // start here; switch to 'enforce' when clean\n * r2Url: process.env.R2_PUBLIC_URL,\n * turnstile: true, analytics: true,\n * })\n * const headers = buildSecurityHeaders({ hsts: prod, additional: [csp] })\n *\n * Deploy CSP carefully: start with mode:'report-only', check the console for\n * violations across the whole site (forms/Turnstile, gallery/R2, embeds), add\n * missing sources via `extra`, THEN switch to 'enforce'. See docs/security.md.\n */\nexport function buildCsp(args: BuildCspArgs = {}): { key: string; value: string } {\n const { analytics, extra = {}, googleMaps, mode = 'enforce', r2Url, turnstile, youtube } = args\n\n const src: Record = {\n 'default-src': [\"'self'\"],\n // 'unsafe-inline' is hard to avoid with Next/analytics; 'unsafe-eval' is NOT\n // added by default (weakens CSP) — add via extra only if a library needs it.\n 'connect-src': [\"'self'\"],\n 'font-src': [\"'self'\", 'https://fonts.gstatic.com', 'data:'],\n 'form-action': [\"'self'\"],\n 'frame-src': [],\n 'img-src': [\"'self'\", 'data:', 'blob:'],\n 'media-src': [], // video/audio sources — filled via extra when needed\n 'script-src': [\"'self'\", \"'unsafe-inline'\"],\n 'style-src': [\"'self'\", \"'unsafe-inline'\", 'https://fonts.googleapis.com'],\n 'worker-src': [], // web workers — filled via extra when needed\n // HARD defaults (OWASP/Lighthouse) — always on, no reason to omit:\n 'base-uri': [\"'self'\"], // block hijacking\n 'frame-ancestors': [\"'none'\"], // clickjacking protection (replaces X-Frame-Options)\n 'object-src': [\"'none'\"], // block / (Flash-era attack surface)\n }\n\n if (r2Url) {src['img-src'].push(r2Url)}\n\n if (turnstile) {\n src['script-src'].push('https://challenges.cloudflare.com')\n src['frame-src'].push('https://challenges.cloudflare.com')\n src['connect-src'].push('https://challenges.cloudflare.com')\n }\n\n if (analytics) {\n src['script-src'].push('https://www.googletagmanager.com')\n src['connect-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com')\n src['img-src'].push('https://www.google-analytics.com', 'https://www.googletagmanager.com')\n }\n\n if (youtube) {\n src['frame-src'].push('https://www.youtube.com', 'https://www.youtube-nocookie.com')\n }\n\n if (googleMaps) {\n src['frame-src'].push('https://www.google.com', 'https://maps.google.com')\n src['script-src'].push('https://maps.googleapis.com')\n src['img-src'].push('https://maps.gstatic.com', 'https://*.googleapis.com')\n }\n\n // Merge caller extras.\n for (const [dir, values] of Object.entries(extra) as Array<[CspDirective, string[]]>) {\n if (values && values.length) {src[dir] = [...(src[dir] ?? []), ...values]}\n }\n\n const value = (Object.entries(src) as Array<[CspDirective, string[]]>)\n .filter(([, values]) => values.length > 0)\n .map(([dir, values]) => `${dir} ${values.join(' ')}`)\n .join('; ')\n\n const key =\n mode === 'report-only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'\n return { key, value }\n}\n"],"names":["buildCsp","args","analytics","extra","googleMaps","mode","r2Url","turnstile","youtube","src","push","dir","values","Object","entries","length","value","filter","map","join","key"],"mappings":"AAgCA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASA,SAASC,OAAqB,CAAC,CAAC;IAC9C,MAAM,EAAEC,SAAS,EAAEC,QAAQ,CAAC,CAAC,EAAEC,UAAU,EAAEC,OAAO,SAAS,EAAEC,KAAK,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGP;IAE3F,MAAMQ,MAAsC;QAC1C,eAAe;YAAC;SAAS;QACzB,6EAA6E;QAC7E,6EAA6E;QAC7E,eAAe;YAAC;SAAS;QACzB,YAAY;YAAC;YAAU;YAA6B;SAAQ;QAC5D,eAAe;YAAC;SAAS;QACzB,aAAa,EAAE;QACf,WAAW;YAAC;YAAU;YAAS;SAAQ;QACvC,aAAa,EAAE;QACf,cAAc;YAAC;YAAU;SAAkB;QAC3C,aAAa;YAAC;YAAU;YAAmB;SAA+B;QAC1E,cAAc,EAAE;QAChB,mEAAmE;QACnE,YAAY;YAAC;SAAS;QACtB,mBAAmB;YAAC;SAAS;QAC7B,cAAc;YAAC;SAAS;IAC1B;IAEA,IAAIH,OAAO;QAACG,GAAG,CAAC,UAAU,CAACC,IAAI,CAACJ;IAAM;IAEtC,IAAIC,WAAW;QACbE,GAAG,CAAC,aAAa,CAACC,IAAI,CAAC;QACvBD,GAAG,CAAC,YAAY,CAACC,IAAI,CAAC;QACtBD,GAAG,CAAC,cAAc,CAACC,IAAI,CAAC;IAC1B;IAEA,IAAIR,WAAW;QACbO,GAAG,CAAC,aAAa,CAACC,IAAI,CAAC;QACvBD,GAAG,CAAC,cAAc,CAACC,IAAI,CAAC,oCAAoC;QAC5DD,GAAG,CAAC,UAAU,CAACC,IAAI,CAAC,oCAAoC;IAC1D;IAEA,IAAIF,SAAS;QACXC,GAAG,CAAC,YAAY,CAACC,IAAI,CAAC,2BAA2B;IACnD;IAEA,IAAIN,YAAY;QACdK,GAAG,CAAC,YAAY,CAACC,IAAI,CAAC,0BAA0B;QAChDD,GAAG,CAAC,aAAa,CAACC,IAAI,CAAC;QACvBD,GAAG,CAAC,UAAU,CAACC,IAAI,CAAC,4BAA4B;IAClD;IAEA,uBAAuB;IACvB,KAAK,MAAM,CAACC,KAAKC,OAAO,IAAIC,OAAOC,OAAO,CAACX,OAA2C;QACpF,IAAIS,UAAUA,OAAOG,MAAM,EAAE;YAACN,GAAG,CAACE,IAAI,GAAG;mBAAKF,GAAG,CAACE,IAAI,IAAI,EAAE;mBAAMC;aAAO;QAAA;IAC3E;IAEA,MAAMI,QAAQ,AAACH,OAAOC,OAAO,CAACL,KAC3BQ,MAAM,CAAC,CAAC,GAAGL,OAAO,GAAKA,OAAOG,MAAM,GAAG,GACvCG,GAAG,CAAC,CAAC,CAACP,KAAKC,OAAO,GAAK,GAAGD,IAAI,CAAC,EAAEC,OAAOO,IAAI,CAAC,MAAM,EACnDA,IAAI,CAAC;IAER,MAAMC,MACJf,SAAS,gBAAgB,wCAAwC;IACnE,OAAO;QAAEe;QAAKJ;IAAM;AACtB"} \ No newline at end of file diff --git a/src/modules/security/buildCsp.ts b/src/modules/security/buildCsp.ts index 91426c9..7a9df50 100644 --- a/src/modules/security/buildCsp.ts +++ b/src/modules/security/buildCsp.ts @@ -24,9 +24,11 @@ type CspDirective = | '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 @@ -64,8 +66,10 @@ export function buildCsp(args: BuildCspArgs = {}): { key: string; value: string '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 hijacking 'frame-ancestors': ["'none'"], // clickjacking protection (replaces X-Frame-Options)