Added security scripts support

This commit is contained in:
2026-09-19 22:07:57 +02:00
parent 1186f4f620
commit 9101a5b48e
17 changed files with 404 additions and 42 deletions
+46
View File
@@ -0,0 +1,46 @@
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' | 'object-src' | 'script-src' | 'style-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 declare function buildCsp(args?: BuildCspArgs): {
key: string;
value: string;
};
export {};
+107
View File
@@ -0,0 +1,107 @@
/**
* 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
File diff suppressed because one or more lines are too long
+8
View File
@@ -13,6 +13,14 @@ export type BuildSecurityHeadersArgs = {
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
*/
additional?: SecurityHeader[];
/**
* Cross-Origin-Opener-Policy. Default 'same-origin' — isolates the browsing
* context so a malicious page can't hold a window.opener reference (protects
* against XS-Leaks / Spectre-class attacks). Project-independent, so it's a
* default. Use 'same-origin-allow-popups' if you open OAuth/payment popups
* that need window.opener; false to omit.
*/
coop?: 'same-origin' | 'same-origin-allow-popups' | false;
/**
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
+8 -1
View File
@@ -26,7 +26,7 @@
* },
* }
*/ export function buildSecurityHeaders(args = {}) {
const { additional = [], frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args;
const { additional = [], coop = 'same-origin', frameOptions = 'DENY', hsts = true, hstsIncludeSubDomains = true, hstsMaxAge = 63072000, hstsPreload = false, permissionsPolicy = 'camera=(), microphone=(), geolocation=()', referrerPolicy = 'strict-origin-when-cross-origin' } = args;
const headers = [];
if (hsts) {
const parts = [
@@ -66,6 +66,13 @@
value: permissionsPolicy
});
}
// COOP — isolates the browsing context (XS-Leaks / Spectre protection).
if (coop) {
headers.push({
key: 'Cross-Origin-Opener-Policy',
value: coop
});
}
// Merge additional: same-key entries override the defaults above.
for (const extra of additional){
const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase());
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,2 +1,4 @@
export { buildCsp } from './buildCsp.js';
export type { BuildCspArgs } from './buildCsp.js';
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
export type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js';
+1
View File
@@ -1,3 +1,4 @@
export { buildCsp } from './buildCsp.js';
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
//# sourceMappingURL=index.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'\n"],"names":["buildSecurityHeaders"],"mappings":"AAAA,SAASA,oBAAoB,QAAQ,4BAA2B"}
{"version":3,"sources":["../../../src/modules/security/index.ts"],"sourcesContent":["export { buildCsp } from './buildCsp.js'\nexport type { BuildCspArgs } from './buildCsp.js'\nexport { buildSecurityHeaders } from './buildSecurityHeaders.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js'\n"],"names":["buildCsp","buildSecurityHeaders"],"mappings":"AAAA,SAASA,QAAQ,QAAQ,gBAAe;AAExC,SAASC,oBAAoB,QAAQ,4BAA2B"}