Added security scripts support
This commit is contained in:
+22
-25
@@ -75,18 +75,13 @@ export {
|
||||
} from './modules/i18n/index.js'
|
||||
export type { LocaleMiddlewareResult } 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 {
|
||||
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 { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
|
||||
export type { GlobalQueryOptions } from './modules/payload/index.js'
|
||||
@@ -98,24 +93,10 @@ export {
|
||||
SITE_SETTINGS_SLUG,
|
||||
} from './modules/payload/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 {
|
||||
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 { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'
|
||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'
|
||||
@@ -127,9 +108,25 @@ export {
|
||||
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'
|
||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||
export { buildR2Storage } from './modules/storage/index.js'
|
||||
|
||||
export { ipalKit } from './plugin.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.
|
||||
*/
|
||||
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
|
||||
@@ -69,6 +77,7 @@ export type BuildSecurityHeadersArgs = {
|
||||
export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {
|
||||
const {
|
||||
additional = [],
|
||||
coop = 'same-origin',
|
||||
frameOptions = 'DENY',
|
||||
hsts = true,
|
||||
hstsIncludeSubDomains = true,
|
||||
@@ -102,6 +111,11 @@ export function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): Secur
|
||||
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.
|
||||
for (const extra of additional) {
|
||||
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 type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js'
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader, } from './buildSecurityHeaders.js'
|
||||
|
||||
Reference in New Issue
Block a user