Added security scripts support
This commit is contained in:
Vendored
+8
-3
@@ -22,19 +22,24 @@ export type { LocaleMiddlewareResult } from './modules/i18n/index.js';
|
|||||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js';
|
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js';
|
||||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js';
|
export type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js';
|
||||||
export type { PagesOption, SystemPageRole } from './modules/pages/index.js';
|
export type { PagesOption, SystemPageRole } from './modules/pages/index.js';
|
||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export type { GlobalQueryOptions } from './modules/payload/index.js';
|
export type { GlobalQueryOptions } from './modules/payload/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG, } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
|
export { buildCsp } from './modules/security/index.js';
|
||||||
|
export type { BuildCspArgs } from './modules/security/index.js';
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js';
|
||||||
export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js';
|
||||||
|
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField, } from './modules/seo/index.js';
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd, } from './modules/seo/index.js';
|
||||||
|
export { buildFaqJsonLd, buildLocalBusinessJsonLd, buildServiceJsonLd, } from './modules/seo/index.js';
|
||||||
|
export { buildArticleJsonLd } from './modules/seo/index.js';
|
||||||
|
export { buildLlmsTxt } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
export { buildR2Storage } from './modules/storage/index.js';
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|||||||
Vendored
+7
-6
@@ -12,20 +12,21 @@ export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
|||||||
export { createContentHelpers } from './modules/frontend/index.js';
|
export { createContentHelpers } from './modules/frontend/index.js';
|
||||||
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
|
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
|
||||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||||
// Media — filename normalization hook for upload collections (Media).
|
|
||||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/index.js';
|
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/index.js';
|
||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export { buildArticleJsonLd, buildFaqJsonLd, buildIconsMetadata, buildLlmsTxt, buildLocalBusinessJsonLd, buildOrganizationJsonLd, buildServiceJsonLd, validateFaviconField } from './modules/seo/index.js';
|
export { buildCsp } from './modules/security/index.js';
|
||||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
|
||||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
|
||||||
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||||
|
export { buildIconsMetadata, buildOrganizationJsonLd, validateFaviconField } from './modules/seo/index.js';
|
||||||
|
export { buildBreadcrumbJsonLd, buildSiteNavigationJsonLd, buildWebSiteJsonLd } from './modules/seo/index.js';
|
||||||
|
// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.
|
||||||
|
export { buildFaqJsonLd, buildLocalBusinessJsonLd, buildServiceJsonLd } from './modules/seo/index.js';
|
||||||
|
export { buildArticleJsonLd } from './modules/seo/index.js';
|
||||||
|
export { buildLlmsTxt } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
|
||||||
export { buildR2Storage } from './modules/storage/index.js';
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+46
@@ -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 {};
|
||||||
Vendored
+107
@@ -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
|
||||||
+1
File diff suppressed because one or more lines are too long
@@ -13,6 +13,14 @@ export type BuildSecurityHeadersArgs = {
|
|||||||
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
|
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
|
||||||
*/
|
*/
|
||||||
additional?: SecurityHeader[];
|
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'
|
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
|
||||||
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
|
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
|
||||||
|
|||||||
+8
-1
@@ -26,7 +26,7 @@
|
|||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
*/ export function buildSecurityHeaders(args = {}) {
|
*/ 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 = [];
|
const headers = [];
|
||||||
if (hsts) {
|
if (hsts) {
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -66,6 +66,13 @@
|
|||||||
value: permissionsPolicy
|
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.
|
// Merge additional: same-key entries override the defaults above.
|
||||||
for (const extra of additional){
|
for (const extra of additional){
|
||||||
const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase());
|
const i = headers.findIndex((h)=>h.key.toLowerCase() === extra.key.toLowerCase());
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
|
export { buildCsp } from './buildCsp.js';
|
||||||
|
export type { BuildCspArgs } from './buildCsp.js';
|
||||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
|
export type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js';
|
||||||
|
|||||||
Vendored
+1
@@ -1,3 +1,4 @@
|
|||||||
|
export { buildCsp } from './buildCsp.js';
|
||||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
-1
@@ -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"}
|
||||||
+61
-2
@@ -71,7 +71,44 @@ analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (`*` =
|
|||||||
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (`additional`),
|
||||||
projekt dostarcza CSP dopasowany do siebie.
|
projekt dostarcza CSP dopasowany do siebie.
|
||||||
|
|
||||||
### Budowa CSP — domeny z env, nie hardkod
|
### buildCsp — generator CSP (zalecane zamiast ręcznego)
|
||||||
|
|
||||||
|
Zamiast pisać surowy CSP w każdym projekcie (ryzyko pominięcia base-uri,
|
||||||
|
object-src), użyj `buildCsp` — ma twarde reguły OWASP/Lighthouse wbudowane, a Ty
|
||||||
|
włączasz tylko flagi tego, co projekt ładuje:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// next.config.ts
|
||||||
|
import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||||
|
|
||||||
|
const csp = buildCsp({
|
||||||
|
mode: 'report-only', // zacznij tu; 'enforce' gdy konsola czysta
|
||||||
|
r2Url: process.env.R2_PUBLIC_URL, // media R2 → img-src
|
||||||
|
turnstile: true, // challenges.cloudflare.com → script/frame/connect
|
||||||
|
analytics: true, // GTM + GA
|
||||||
|
youtube: true, // youtube → frame-src
|
||||||
|
googleMaps: true, // mapy Google
|
||||||
|
// extra: { 'script-src': ['https://inny-skrypt.pl'] }, // dodatkowe źródła
|
||||||
|
})
|
||||||
|
|
||||||
|
const securityHeaders = buildSecurityHeaders({
|
||||||
|
hsts: process.env.NODE_ENV === 'production',
|
||||||
|
additional: [csp],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Twarde reguły wbudowane** (zawsze, nie da się zapomnieć): `base-uri 'self'`,
|
||||||
|
`object-src 'none'`, `frame-ancestors 'none'`. To te, które Lighthouse/OWASP
|
||||||
|
wymagają, a łatwo je pominąć pisząc CSP ręcznie.
|
||||||
|
|
||||||
|
`buildCsp` NIE dodaje `'unsafe-eval'` (osłabia CSP) — dodaj przez `extra` tylko
|
||||||
|
jeśli biblioteka tego wymaga. `mode: 'report-only'` daje nagłówek
|
||||||
|
`…-Report-Only`; `'enforce'` daje `Content-Security-Policy`.
|
||||||
|
|
||||||
|
CSP dalej „w projekcie" (Ty wybierasz flagi wg tego, co ładujesz), ale skeleton
|
||||||
|
jest z pluginu — każdy projekt ma ten sam zahardowany fundament.
|
||||||
|
|
||||||
|
### Budowa CSP — ręcznie (jeśli potrzebujesz pełnej kontroli)
|
||||||
|
|
||||||
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
Domenę mediów czytaj z `R2_PUBLIC_URL` (env), nie zaszywaj. Resztę źródeł
|
||||||
dopasuj do tego, co projekt faktycznie ładuje:
|
dopasuj do tego, co projekt faktycznie ładuje:
|
||||||
@@ -138,4 +175,26 @@ wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.
|
|||||||
|
|
||||||
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
> Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować
|
||||||
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
> nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header)
|
||||||
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
> albo upewnij się, że CF przepuszcza nagłówki z origin.
|
||||||
|
|
||||||
|
|
||||||
|
## COOP (Cross-Origin-Opener-Policy) — domyślnie włączony
|
||||||
|
|
||||||
|
buildSecurityHeaders wysyła domyślnie `Cross-Origin-Opener-Policy: same-origin` —
|
||||||
|
izoluje kontekst przeglądarki (ochrona przed XS-Leaks / Spectre, wyciekiem
|
||||||
|
window.opener). Uniwersalny nagłówek, więc z automatu.
|
||||||
|
|
||||||
|
- Domyślnie `same-origin` (najbezpieczniejsze)
|
||||||
|
- `coop: 'same-origin-allow-popups'` — jeśli otwierasz popupy OAuth/płatności
|
||||||
|
wymagające window.opener
|
||||||
|
- `coop: false` — wyłącz (rzadko potrzebne)
|
||||||
|
|
||||||
|
## Trusted Types — NIE wdrażać (na teraz)
|
||||||
|
|
||||||
|
NIE wymuszaj `require-trusted-types-for 'script'`. Powód:
|
||||||
|
- Audyt Lighthouse to „Bez oceny" (informacyjny/eksperymentalny w Chromium)
|
||||||
|
- Wymuszenie bez kompleksowego silnika polityk w Next/React powoduje `TypeError`
|
||||||
|
przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA)
|
||||||
|
- Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony
|
||||||
|
|
||||||
|
Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia.
|
||||||
+22
-25
@@ -75,18 +75,13 @@ export {
|
|||||||
} from './modules/i18n/index.js'
|
} from './modules/i18n/index.js'
|
||||||
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
|
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
|
||||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
|
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
|
||||||
// Media — filename normalization hook for upload collections (Media).
|
|
||||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'
|
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'
|
||||||
export {
|
export {
|
||||||
getNotificationTexts,
|
getNotificationTexts,
|
||||||
NOTIFICATION_FALLBACK,
|
NOTIFICATION_FALLBACK,
|
||||||
resolveFormMessage,
|
resolveFormMessage,
|
||||||
} from './modules/notifications/index.js'
|
} from './modules/notifications/index.js'
|
||||||
export type {
|
export type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js'
|
||||||
FormNotificationTexts,
|
|
||||||
NotificationsData,
|
|
||||||
NotificationTexts,
|
|
||||||
} from './modules/notifications/index.js'
|
|
||||||
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
|
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
|
||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
|
||||||
export type { GlobalQueryOptions } from './modules/payload/index.js'
|
export type { GlobalQueryOptions } from './modules/payload/index.js'
|
||||||
@@ -98,24 +93,10 @@ export {
|
|||||||
SITE_SETTINGS_SLUG,
|
SITE_SETTINGS_SLUG,
|
||||||
} from './modules/payload/index.js'
|
} from './modules/payload/index.js'
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js'
|
export { buildSecurityHeaders } from './modules/security/index.js'
|
||||||
|
export { buildCsp } from './modules/security/index.js'
|
||||||
|
export type { BuildCspArgs } from './modules/security/index.js'
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
export type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'
|
||||||
export {
|
|
||||||
buildArticleJsonLd,
|
|
||||||
buildFaqJsonLd,
|
|
||||||
buildIconsMetadata,
|
|
||||||
buildLlmsTxt,
|
|
||||||
buildLocalBusinessJsonLd,
|
|
||||||
buildOrganizationJsonLd,
|
|
||||||
buildServiceJsonLd,
|
|
||||||
validateFaviconField,
|
|
||||||
} from './modules/seo/index.js'
|
|
||||||
// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.
|
|
||||||
// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.
|
|
||||||
export {
|
|
||||||
buildBreadcrumbJsonLd,
|
|
||||||
buildSiteNavigationJsonLd,
|
|
||||||
buildWebSiteJsonLd,
|
|
||||||
} from './modules/seo/index.js'
|
|
||||||
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||||
@@ -127,9 +108,25 @@ export {
|
|||||||
createPageMetadata,
|
createPageMetadata,
|
||||||
injectAutoFillMeta,
|
injectAutoFillMeta,
|
||||||
} from './modules/seo/index.js'
|
} from './modules/seo/index.js'
|
||||||
|
export {
|
||||||
|
buildIconsMetadata,
|
||||||
|
buildOrganizationJsonLd,
|
||||||
|
validateFaviconField,
|
||||||
|
} from './modules/seo/index.js'
|
||||||
|
export {
|
||||||
|
buildBreadcrumbJsonLd,
|
||||||
|
buildSiteNavigationJsonLd,
|
||||||
|
buildWebSiteJsonLd,
|
||||||
|
} from './modules/seo/index.js'
|
||||||
|
// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.
|
||||||
|
export {
|
||||||
|
buildFaqJsonLd,
|
||||||
|
buildLocalBusinessJsonLd,
|
||||||
|
buildServiceJsonLd,
|
||||||
|
} from './modules/seo/index.js'
|
||||||
|
export { buildArticleJsonLd } from './modules/seo/index.js'
|
||||||
|
export { buildLlmsTxt } from './modules/seo/index.js'
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
||||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
|
||||||
export { buildR2Storage } from './modules/storage/index.js'
|
export { buildR2Storage } from './modules/storage/index.js'
|
||||||
|
|
||||||
export { ipalKit } from './plugin.js'
|
export { ipalKit } from './plugin.js'
|
||||||
export type { IpalOptions } from './types.js'
|
export type { IpalOptions } from './types.js'
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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 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:'],
|
||||||
|
'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'"], // 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 }
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ export type BuildSecurityHeadersArgs = {
|
|||||||
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
|
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
|
||||||
*/
|
*/
|
||||||
additional?: SecurityHeader[]
|
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'
|
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
|
||||||
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
|
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
|
||||||
@@ -69,6 +77,7 @@ export type BuildSecurityHeadersArgs = {
|
|||||||
export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {
|
export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {
|
||||||
const {
|
const {
|
||||||
additional = [],
|
additional = [],
|
||||||
|
coop = 'same-origin',
|
||||||
frameOptions = 'DENY',
|
frameOptions = 'DENY',
|
||||||
hsts = true,
|
hsts = true,
|
||||||
hstsIncludeSubDomains = true,
|
hstsIncludeSubDomains = true,
|
||||||
@@ -102,6 +111,11 @@ export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): Secur
|
|||||||
headers.push({ key: 'Permissions-Policy', value: permissionsPolicy })
|
headers.push({ key: 'Permissions-Policy', 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.
|
// Merge additional: same-key entries override the defaults above.
|
||||||
for (const extra of additional) {
|
for (const extra of additional) {
|
||||||
const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase())
|
const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase())
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
export { buildCsp } from './buildCsp.js'
|
||||||
|
export type { BuildCspArgs } from './buildCsp.js'
|
||||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js'
|
export { buildSecurityHeaders } from './buildSecurityHeaders.js'
|
||||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'
|
export type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js'
|
||||||
|
|||||||
Reference in New Issue
Block a user