diff --git a/dist/index.d.ts b/dist/index.d.ts index f71bd20..6bf35a2 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -22,19 +22,24 @@ export type { LocaleMiddlewareResult } from './modules/i18n/index.js'; export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'; 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'; export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, 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'; -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'; 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 { buildR2Storage } from './modules/storage/index.js'; export { ipalKit } from './plugin.js'; diff --git a/dist/index.js b/dist/index.js index f43adf7..386edd2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -12,20 +12,21 @@ export { buildFormsPlugin } from './modules/forms/formsPluginConfig.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 { 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 { 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 { buildSecurityHeaders } 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 { buildCsp } from './modules/security/index.js'; export { buildHreflangAlternates, buildMetadata, composeTitle } 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'; -// Storage — Cloudflare R2 media offload, configured from .env. export { buildR2Storage } from './modules/storage/index.js'; export { ipalKit } from './plugin.js'; diff --git a/dist/index.js.map b/dist/index.js.map index e879239..233a022 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\n// Media — filename normalization hook for upload collections (Media).\nexport { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'\nexport {\n getNotificationTexts,\n NOTIFICATION_FALLBACK,\n resolveFormMessage,\n} from './modules/notifications/index.js'\nexport type {\n FormNotificationTexts,\n NotificationsData,\n NotificationTexts,\n} from './modules/notifications/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\nexport {\n buildArticleJsonLd,\n buildFaqJsonLd,\n buildIconsMetadata,\n buildLlmsTxt,\n buildLocalBusinessJsonLd,\n buildOrganizationJsonLd,\n buildServiceJsonLd,\n validateFaviconField,\n} from './modules/seo/index.js'\n// Structured data (schema.org JSON-LD) — brand/sitelink signals for Google.\n// WebSite (+ optional SearchAction), BreadcrumbList (per page), SiteNavigation.\nexport {\n buildBreadcrumbJsonLd,\n buildSiteNavigationJsonLd,\n buildWebSiteJsonLd,\n} from './modules/seo/index.js'\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\n// Storage — Cloudflare R2 media offload, configured from .env.\nexport { buildR2Storage } from './modules/storage/index.js'\n\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","normalizeFilename","normalizeFilenameHook","getNotificationTexts","NOTIFICATION_FALLBACK","resolveFormMessage","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildArticleJsonLd","buildFaqJsonLd","buildIconsMetadata","buildLlmsTxt","buildLocalBusinessJsonLd","buildOrganizationJsonLd","buildServiceJsonLd","validateFaviconField","buildBreadcrumbJsonLd","buildSiteNavigationJsonLd","buildWebSiteJsonLd","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildSlugField","toSlug","buildR2Storage","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAC5F,sEAAsE;AACtE,SAASC,iBAAiB,EAAEC,qBAAqB,QAAQ,2BAA0B;AACnF,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,kBAAkB,QACb,mCAAkC;AAOzC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,cAAc,EACdC,kBAAkB,EAClBC,YAAY,EACZC,wBAAwB,EACxBC,uBAAuB,EACvBC,kBAAkB,EAClBC,oBAAoB,QACf,yBAAwB;AAC/B,4EAA4E;AAC5E,gFAAgF;AAChF,SACEC,qBAAqB,EACrBC,yBAAyB,EACzBC,kBAAkB,QACb,yBAAwB;AAE/B,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,+DAA+D;AAC/D,SAASC,cAAc,QAAQ,6BAA4B;AAE3D,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file +{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type { AccessOption, Role } from './modules/access/index.js'\nexport {\n adminOnly,\n adminOnlyField,\n adminOrEditor,\n adminOrEditorField,\n adminOrSelf,\n authenticated,\n hasMinimumRole,\n isAdmin,\n isEditor,\n requireRole,\n requireRoleField,\n ROLE_HIERARCHY,\n} from './modules/access/index.js'\nexport type { AnalyticsConfig } from './modules/analytics/index.js'\nexport { getAnalyticsConfig } from './modules/analytics/index.js'\nexport {\n ACCEPT_ALL_CONSENT,\n CONSENT_CATEGORIES,\n CONSENT_COOKIE,\n CONSENT_MAX_AGE,\n CONSENT_VERSION,\n DEFAULT_CONSENT,\n getConsentTexts,\n parseConsent,\n REJECT_ALL_CONSENT,\n serializeConsent,\n setDefaultConsent,\n updateConsent,\n} from './modules/consent/index.js'\nexport type { ConsentCategory, ConsentState, ConsentTexts } from './modules/consent/index.js'\nexport type {\n ContentCollectionOption,\n ContentOption,\n ResolvedRoute,\n} from './modules/content/index.js'\nexport {\n archiveFieldName,\n buildArchivePath,\n buildEntryPath,\n getArchiveEntries,\n parsePageParam,\n resolveRoute,\n} from './modules/content/index.js'\nexport type { ArchiveEntries } from './modules/content/index.js'\nexport { graphAdapter } from './modules/email/graphAdapter.js'\nexport type { GraphAdapterArgs } from './modules/email/graphAdapter.js'\nexport { mailAdapter } from './modules/email/mailAdapter.js'\nexport type { MailAdapterArgs } from './modules/email/mailAdapter.js'\n// Imported straight from the file, NOT from ./modules/email/index.js — that\n// barrel re-exports sendEmail, which imports 'server-only' and would crash when\n// Payload loads the config (or runs generate:importmap) as a plain Node script.\nexport { panelSmtpAdapter } from './modules/email/panelSmtpAdapter.js'\nexport type { PanelSmtpAdapterArgs } from './modules/email/panelSmtpAdapter.js'\nexport { buildFormsPlugin } from './modules/forms/formsPluginConfig.js'\nexport type {\n FormsCollectionOverrides,\n FormsFieldsOverride,\n FormsOption,\n} from './modules/forms/types.js'\nexport { createContentHelpers } from './modules/frontend/index.js'\nexport type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18n/index.js'\nexport {\n buildLocalizedPath,\n getDefaultLocale,\n getLocaleCodes,\n getLocaleDefinition,\n getLocalizedSlugs,\n isValidLocale,\n LOCALE_COOKIE_NAME,\n matchAcceptLanguage,\n negotiateLocale,\n switchLocalePath,\n} from './modules/i18n/index.js'\nexport type { LocaleMiddlewareResult } from './modules/i18n/index.js'\nexport { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'\nexport { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'\nexport {\n getNotificationTexts,\n NOTIFICATION_FALLBACK,\n resolveFormMessage,\n} from './modules/notifications/index.js'\nexport type { FormNotificationTexts, NotificationTexts } from './modules/notifications/index.js'\nexport type { PagesOption, SystemPageRole } from './modules/pages/index.js'\nexport { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'\nexport type { GlobalQueryOptions } from './modules/payload/index.js'\nexport {\n getGlobal,\n getSiteIntegrations,\n getSiteSettings,\n SITE_INTEGRATIONS_SLUG,\n SITE_SETTINGS_SLUG,\n} from './modules/payload/index.js'\nexport { buildSecurityHeaders } from './modules/security/index.js'\nexport { buildCsp } from './modules/security/index.js'\nexport type { BuildCspArgs } from './modules/security/index.js'\nexport type { BuildSecurityHeadersArgs, SecurityHeader } from './modules/security/index.js'\n\nexport type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js'\nexport { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js'\nexport type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js'\nexport {\n buildAutoFillMetaHook,\n buildRobots,\n buildSitemapEntries,\n createMetadataGenerator,\n createPageMetadata,\n injectAutoFillMeta,\n} from './modules/seo/index.js'\nexport {\n buildIconsMetadata,\n buildOrganizationJsonLd,\n validateFaviconField,\n} from './modules/seo/index.js'\nexport {\n buildBreadcrumbJsonLd,\n buildSiteNavigationJsonLd,\n buildWebSiteJsonLd,\n} from './modules/seo/index.js'\n// Local SEO structured data — LocalBusiness (map pack), Service (offering), FAQPage.\nexport {\n buildFaqJsonLd,\n buildLocalBusinessJsonLd,\n buildServiceJsonLd,\n} from './modules/seo/index.js'\nexport { buildArticleJsonLd } from './modules/seo/index.js'\nexport { buildLlmsTxt } from './modules/seo/index.js'\nexport { buildSlugField, toSlug } from './modules/slug/index.js'\nexport { buildR2Storage } from './modules/storage/index.js'\nexport { ipalKit } from './plugin.js'\nexport type { IpalOptions } from './types.js'\n"],"names":["adminOnly","adminOnlyField","adminOrEditor","adminOrEditorField","adminOrSelf","authenticated","hasMinimumRole","isAdmin","isEditor","requireRole","requireRoleField","ROLE_HIERARCHY","getAnalyticsConfig","ACCEPT_ALL_CONSENT","CONSENT_CATEGORIES","CONSENT_COOKIE","CONSENT_MAX_AGE","CONSENT_VERSION","DEFAULT_CONSENT","getConsentTexts","parseConsent","REJECT_ALL_CONSENT","serializeConsent","setDefaultConsent","updateConsent","archiveFieldName","buildArchivePath","buildEntryPath","getArchiveEntries","parsePageParam","resolveRoute","graphAdapter","mailAdapter","panelSmtpAdapter","buildFormsPlugin","createContentHelpers","buildLocalizedPath","getDefaultLocale","getLocaleCodes","getLocaleDefinition","getLocalizedSlugs","isValidLocale","LOCALE_COOKIE_NAME","matchAcceptLanguage","negotiateLocale","switchLocalePath","createLocaleMiddleware","DEFAULT_MIDDLEWARE_MATCHER","normalizeFilename","normalizeFilenameHook","getNotificationTexts","NOTIFICATION_FALLBACK","resolveFormMessage","ALL_SYSTEM_PAGE_ROLES","getSystemPagePath","getGlobal","getSiteIntegrations","getSiteSettings","SITE_INTEGRATIONS_SLUG","SITE_SETTINGS_SLUG","buildSecurityHeaders","buildCsp","buildHreflangAlternates","buildMetadata","composeTitle","buildAutoFillMetaHook","buildRobots","buildSitemapEntries","createMetadataGenerator","createPageMetadata","injectAutoFillMeta","buildIconsMetadata","buildOrganizationJsonLd","validateFaviconField","buildBreadcrumbJsonLd","buildSiteNavigationJsonLd","buildWebSiteJsonLd","buildFaqJsonLd","buildLocalBusinessJsonLd","buildServiceJsonLd","buildArticleJsonLd","buildLlmsTxt","buildSlugField","toSlug","buildR2Storage","ipalKit"],"mappings":"AACA,SACEA,SAAS,EACTC,cAAc,EACdC,aAAa,EACbC,kBAAkB,EAClBC,WAAW,EACXC,aAAa,EACbC,cAAc,EACdC,OAAO,EACPC,QAAQ,EACRC,WAAW,EACXC,gBAAgB,EAChBC,cAAc,QACT,4BAA2B;AAElC,SAASC,kBAAkB,QAAQ,+BAA8B;AACjE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,cAAc,EACdC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,eAAe,EACfC,YAAY,EACZC,kBAAkB,EAClBC,gBAAgB,EAChBC,iBAAiB,EACjBC,aAAa,QACR,6BAA4B;AAOnC,SACEC,gBAAgB,EAChBC,gBAAgB,EAChBC,cAAc,EACdC,iBAAiB,EACjBC,cAAc,EACdC,YAAY,QACP,6BAA4B;AAEnC,SAASC,YAAY,QAAQ,kCAAiC;AAE9D,SAASC,WAAW,QAAQ,iCAAgC;AAE5D,4EAA4E;AAC5E,gFAAgF;AAChF,gFAAgF;AAChF,SAASC,gBAAgB,QAAQ,sCAAqC;AAEtE,SAASC,gBAAgB,QAAQ,uCAAsC;AAMvE,SAASC,oBAAoB,QAAQ,8BAA6B;AAElE,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,cAAc,EACdC,mBAAmB,EACnBC,iBAAiB,EACjBC,aAAa,EACbC,kBAAkB,EAClBC,mBAAmB,EACnBC,eAAe,EACfC,gBAAgB,QACX,0BAAyB;AAEhC,SAASC,sBAAsB,EAAEC,0BAA0B,QAAQ,0BAAyB;AAC5F,SAASC,iBAAiB,EAAEC,qBAAqB,QAAQ,2BAA0B;AACnF,SACEC,oBAAoB,EACpBC,qBAAqB,EACrBC,kBAAkB,QACb,mCAAkC;AAGzC,SAASC,qBAAqB,EAAEC,iBAAiB,QAAQ,2BAA0B;AAEnF,SACEC,SAAS,EACTC,mBAAmB,EACnBC,eAAe,EACfC,sBAAsB,EACtBC,kBAAkB,QACb,6BAA4B;AACnC,SAASC,oBAAoB,QAAQ,8BAA6B;AAClE,SAASC,QAAQ,QAAQ,8BAA6B;AAKtD,SAASC,uBAAuB,EAAEC,aAAa,EAAEC,YAAY,QAAQ,yBAAwB;AAE7F,SACEC,qBAAqB,EACrBC,WAAW,EACXC,mBAAmB,EACnBC,uBAAuB,EACvBC,kBAAkB,EAClBC,kBAAkB,QACb,yBAAwB;AAC/B,SACEC,kBAAkB,EAClBC,uBAAuB,EACvBC,oBAAoB,QACf,yBAAwB;AAC/B,SACEC,qBAAqB,EACrBC,yBAAyB,EACzBC,kBAAkB,QACb,yBAAwB;AAC/B,qFAAqF;AACrF,SACEC,cAAc,EACdC,wBAAwB,EACxBC,kBAAkB,QACb,yBAAwB;AAC/B,SAASC,kBAAkB,QAAQ,yBAAwB;AAC3D,SAASC,YAAY,QAAQ,yBAAwB;AACrD,SAASC,cAAc,EAAEC,MAAM,QAAQ,0BAAyB;AAChE,SAASC,cAAc,QAAQ,6BAA4B;AAC3D,SAASC,OAAO,QAAQ,cAAa"} \ No newline at end of file diff --git a/dist/modules/security/buildCsp.d.ts b/dist/modules/security/buildCsp.d.ts new file mode 100644 index 0000000..812ef7d --- /dev/null +++ b/dist/modules/security/buildCsp.d.ts @@ -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>; + /** 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 {}; diff --git a/dist/modules/security/buildCsp.js b/dist/modules/security/buildCsp.js new file mode 100644 index 0000000..c0993ee --- /dev/null +++ b/dist/modules/security/buildCsp.js @@ -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 \ No newline at end of file diff --git a/dist/modules/security/buildCsp.js.map b/dist/modules/security/buildCsp.js.map new file mode 100644 index 0000000..de1fa0b --- /dev/null +++ b/dist/modules/security/buildCsp.js.map @@ -0,0 +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 diff --git a/dist/modules/security/buildSecurityHeaders.d.ts b/dist/modules/security/buildSecurityHeaders.d.ts index 2c0b7a1..c16e436 100644 --- a/dist/modules/security/buildSecurityHeaders.d.ts +++ b/dist/modules/security/buildSecurityHeaders.d.ts @@ -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 diff --git a/dist/modules/security/buildSecurityHeaders.js b/dist/modules/security/buildSecurityHeaders.js index 3bfadec..7f2a653 100644 --- a/dist/modules/security/buildSecurityHeaders.js +++ b/dist/modules/security/buildSecurityHeaders.js @@ -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()); diff --git a/dist/modules/security/buildSecurityHeaders.js.map b/dist/modules/security/buildSecurityHeaders.js.map index dd8b4e8..e6e666c 100644 --- a/dist/modules/security/buildSecurityHeaders.js.map +++ b/dist/modules/security/buildSecurityHeaders.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/security/buildSecurityHeaders.ts"],"sourcesContent":["/**\n * A single HTTP header, in the shape Next.js next.config headers() expects.\n */\nexport type SecurityHeader = { key: string; value: string }\n\nexport type BuildSecurityHeadersArgs = {\n /**\n * Extra headers to append or override. Same-key entries replace the default,\n * so you can e.g. add your project's Content-Security-Policy here — CSP is\n * intentionally NOT a default because it depends on the project's own\n * domains (scripts, images, fonts, analytics). Keep CSP in your project.\n */\n additional?: SecurityHeader[]\n /**\n * X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'\n * allows same-origin framing. Note: CSP frame-ancestors supersedes this in\n * modern browsers, but X-Frame-Options is kept for older ones. Set to null\n * to omit (e.g. if you set frame-ancestors in your project CSP).\n */\n frameOptions?: 'DENY' | 'SAMEORIGIN' | null\n /**\n * Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and\n * tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF\n * in local/dev over plain HTTP, or you may lock the browser to https on\n * localhost. Set the env guard in your next.config (see docs).\n */\n hsts?: boolean\n /** Add includeSubDomains to HSTS. Default true. */\n hstsIncludeSubDomains?: boolean\n /** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */\n hstsMaxAge?: number\n /** Add preload to HSTS (only if you'll submit to the preload list). Default false. */\n hstsPreload?: boolean\n /**\n * Permissions-Policy. Default disables camera, microphone, geolocation. Pass\n * your own string to override, or null to omit.\n */\n permissionsPolicy?: null | string\n /** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */\n referrerPolicy?: null | string\n}\n\n/**\n * Builds the generic, project-independent security headers every site should\n * send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,\n * Permissions-Policy. These are identical across projects, so the plugin owns\n * the boilerplate; the client spreads the result into next.config's headers().\n *\n * Content-Security-Policy is deliberately excluded: a useful CSP enumerates the\n * exact domains a project loads from (its CDN, analytics, embeds), so it can't\n * be generic without being either too loose (useless) or too strict (breaks the\n * site). Add your project's CSP via `additional`.\n *\n * @example\n * // next.config.ts\n * import { buildSecurityHeaders } from '@intecion/ipal-kit'\n * const securityHeaders = buildSecurityHeaders({\n * hsts: process.env.NODE_ENV === 'production', // off in dev over http\n * additional: [\n * { key: 'Content-Security-Policy', value: \"default-src 'self'; ...\" },\n * ],\n * })\n * const nextConfig = {\n * async headers() {\n * return [{ source: '/:path*', headers: securityHeaders }]\n * },\n * }\n */\nexport function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {\n const {\n additional = [],\n frameOptions = 'DENY',\n hsts = true,\n hstsIncludeSubDomains = true,\n hstsMaxAge = 63072000,\n hstsPreload = false,\n permissionsPolicy = 'camera=(), microphone=(), geolocation=()',\n referrerPolicy = 'strict-origin-when-cross-origin',\n } = args\n\n const headers: SecurityHeader[] = []\n\n if (hsts) {\n const parts = [`max-age=${hstsMaxAge}`]\n if (hstsIncludeSubDomains) {parts.push('includeSubDomains')}\n if (hstsPreload) {parts.push('preload')}\n headers.push({ key: 'Strict-Transport-Security', value: parts.join('; ') })\n }\n\n if (frameOptions) {\n headers.push({ key: 'X-Frame-Options', value: frameOptions })\n }\n\n // Prevents MIME-type sniffing — always safe, no project specifics.\n headers.push({ key: 'X-Content-Type-Options', value: 'nosniff' })\n\n if (referrerPolicy) {\n headers.push({ key: 'Referrer-Policy', value: referrerPolicy })\n }\n\n if (permissionsPolicy) {\n headers.push({ key: 'Permissions-Policy', value: permissionsPolicy })\n }\n\n // Merge additional: same-key entries override the defaults above.\n for (const extra of additional) {\n const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase())\n if (i >= 0) {headers[i] = extra}\n else {headers.push(extra)}\n }\n\n return headers\n}\n"],"names":["buildSecurityHeaders","args","additional","frameOptions","hsts","hstsIncludeSubDomains","hstsMaxAge","hstsPreload","permissionsPolicy","referrerPolicy","headers","parts","push","key","value","join","extra","i","findIndex","h","toLowerCase"],"mappings":"AAAA;;CAEC,GAwCD;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASA,qBAAqBC,OAAiC,CAAC,CAAC;IACtE,MAAM,EACJC,aAAa,EAAE,EACfC,eAAe,MAAM,EACrBC,OAAO,IAAI,EACXC,wBAAwB,IAAI,EAC5BC,aAAa,QAAQ,EACrBC,cAAc,KAAK,EACnBC,oBAAoB,0CAA0C,EAC9DC,iBAAiB,iCAAiC,EACnD,GAAGR;IAEJ,MAAMS,UAA4B,EAAE;IAEpC,IAAIN,MAAM;QACR,MAAMO,QAAQ;YAAC,CAAC,QAAQ,EAAEL,YAAY;SAAC;QACvC,IAAID,uBAAuB;YAACM,MAAMC,IAAI,CAAC;QAAoB;QAC3D,IAAIL,aAAa;YAACI,MAAMC,IAAI,CAAC;QAAU;QACvCF,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAA6BC,OAAOH,MAAMI,IAAI,CAAC;QAAM;IAC3E;IAEA,IAAIZ,cAAc;QAChBO,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAmBC,OAAOX;QAAa;IAC7D;IAEA,mEAAmE;IACnEO,QAAQE,IAAI,CAAC;QAAEC,KAAK;QAA0BC,OAAO;IAAU;IAE/D,IAAIL,gBAAgB;QAClBC,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAmBC,OAAOL;QAAe;IAC/D;IAEA,IAAID,mBAAmB;QACrBE,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAsBC,OAAON;QAAkB;IACrE;IAEA,kEAAkE;IAClE,KAAK,MAAMQ,SAASd,WAAY;QAC9B,MAAMe,IAAIP,QAAQQ,SAAS,CAAC,CAACC,IAAMA,EAAEN,GAAG,CAACO,WAAW,OAAOJ,MAAMH,GAAG,CAACO,WAAW;QAChF,IAAIH,KAAK,GAAG;YAACP,OAAO,CAACO,EAAE,GAAGD;QAAK,OAC1B;YAACN,QAAQE,IAAI,CAACI;QAAM;IAC3B;IAEA,OAAON;AACT"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/security/buildSecurityHeaders.ts"],"sourcesContent":["/**\n * A single HTTP header, in the shape Next.js next.config headers() expects.\n */\nexport type SecurityHeader = { key: string; value: string }\n\nexport type BuildSecurityHeadersArgs = {\n /**\n * Extra headers to append or override. Same-key entries replace the default,\n * so you can e.g. add your project's Content-Security-Policy here — CSP is\n * intentionally NOT a default because it depends on the project's own\n * domains (scripts, images, fonts, analytics). Keep CSP in your project.\n */\n additional?: SecurityHeader[]\n /**\n * Cross-Origin-Opener-Policy. Default 'same-origin' — isolates the browsing\n * context so a malicious page can't hold a window.opener reference (protects\n * against XS-Leaks / Spectre-class attacks). Project-independent, so it's a\n * default. Use 'same-origin-allow-popups' if you open OAuth/payment popups\n * that need window.opener; false to omit.\n */\n coop?: 'same-origin' | 'same-origin-allow-popups' | false\n /**\n * X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'\n * allows same-origin framing. Note: CSP frame-ancestors supersedes this in\n * modern browsers, but X-Frame-Options is kept for older ones. Set to null\n * to omit (e.g. if you set frame-ancestors in your project CSP).\n */\n frameOptions?: 'DENY' | 'SAMEORIGIN' | null\n /**\n * Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and\n * tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF\n * in local/dev over plain HTTP, or you may lock the browser to https on\n * localhost. Set the env guard in your next.config (see docs).\n */\n hsts?: boolean\n /** Add includeSubDomains to HSTS. Default true. */\n hstsIncludeSubDomains?: boolean\n /** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */\n hstsMaxAge?: number\n /** Add preload to HSTS (only if you'll submit to the preload list). Default false. */\n hstsPreload?: boolean\n /**\n * Permissions-Policy. Default disables camera, microphone, geolocation. Pass\n * your own string to override, or null to omit.\n */\n permissionsPolicy?: null | string\n /** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */\n referrerPolicy?: null | string\n}\n\n/**\n * Builds the generic, project-independent security headers every site should\n * send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,\n * Permissions-Policy. These are identical across projects, so the plugin owns\n * the boilerplate; the client spreads the result into next.config's headers().\n *\n * Content-Security-Policy is deliberately excluded: a useful CSP enumerates the\n * exact domains a project loads from (its CDN, analytics, embeds), so it can't\n * be generic without being either too loose (useless) or too strict (breaks the\n * site). Add your project's CSP via `additional`.\n *\n * @example\n * // next.config.ts\n * import { buildSecurityHeaders } from '@intecion/ipal-kit'\n * const securityHeaders = buildSecurityHeaders({\n * hsts: process.env.NODE_ENV === 'production', // off in dev over http\n * additional: [\n * { key: 'Content-Security-Policy', value: \"default-src 'self'; ...\" },\n * ],\n * })\n * const nextConfig = {\n * async headers() {\n * return [{ source: '/:path*', headers: securityHeaders }]\n * },\n * }\n */\nexport function buildSecurityHeaders(args: BuildSecurityHeadersArgs = {}): SecurityHeader[] {\n const {\n additional = [],\n coop = 'same-origin',\n frameOptions = 'DENY',\n hsts = true,\n hstsIncludeSubDomains = true,\n hstsMaxAge = 63072000,\n hstsPreload = false,\n permissionsPolicy = 'camera=(), microphone=(), geolocation=()',\n referrerPolicy = 'strict-origin-when-cross-origin',\n } = args\n\n const headers: SecurityHeader[] = []\n\n if (hsts) {\n const parts = [`max-age=${hstsMaxAge}`]\n if (hstsIncludeSubDomains) {parts.push('includeSubDomains')}\n if (hstsPreload) {parts.push('preload')}\n headers.push({ key: 'Strict-Transport-Security', value: parts.join('; ') })\n }\n\n if (frameOptions) {\n headers.push({ key: 'X-Frame-Options', value: frameOptions })\n }\n\n // Prevents MIME-type sniffing — always safe, no project specifics.\n headers.push({ key: 'X-Content-Type-Options', value: 'nosniff' })\n\n if (referrerPolicy) {\n headers.push({ key: 'Referrer-Policy', value: referrerPolicy })\n }\n\n if (permissionsPolicy) {\n headers.push({ key: 'Permissions-Policy', value: permissionsPolicy })\n }\n\n // COOP — isolates the browsing context (XS-Leaks / Spectre protection).\n if (coop) {\n headers.push({ key: 'Cross-Origin-Opener-Policy', value: coop })\n }\n\n // Merge additional: same-key entries override the defaults above.\n for (const extra of additional) {\n const i = headers.findIndex((h) => h.key.toLowerCase() === extra.key.toLowerCase())\n if (i >= 0) {headers[i] = extra}\n else {headers.push(extra)}\n }\n\n return headers\n}\n"],"names":["buildSecurityHeaders","args","additional","coop","frameOptions","hsts","hstsIncludeSubDomains","hstsMaxAge","hstsPreload","permissionsPolicy","referrerPolicy","headers","parts","push","key","value","join","extra","i","findIndex","h","toLowerCase"],"mappings":"AAAA;;CAEC,GAgDD;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASA,qBAAqBC,OAAiC,CAAC,CAAC;IACtE,MAAM,EACJC,aAAa,EAAE,EACfC,OAAO,aAAa,EACpBC,eAAe,MAAM,EACrBC,OAAO,IAAI,EACXC,wBAAwB,IAAI,EAC5BC,aAAa,QAAQ,EACrBC,cAAc,KAAK,EACnBC,oBAAoB,0CAA0C,EAC9DC,iBAAiB,iCAAiC,EACnD,GAAGT;IAEJ,MAAMU,UAA4B,EAAE;IAEpC,IAAIN,MAAM;QACR,MAAMO,QAAQ;YAAC,CAAC,QAAQ,EAAEL,YAAY;SAAC;QACvC,IAAID,uBAAuB;YAACM,MAAMC,IAAI,CAAC;QAAoB;QAC3D,IAAIL,aAAa;YAACI,MAAMC,IAAI,CAAC;QAAU;QACvCF,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAA6BC,OAAOH,MAAMI,IAAI,CAAC;QAAM;IAC3E;IAEA,IAAIZ,cAAc;QAChBO,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAmBC,OAAOX;QAAa;IAC7D;IAEA,mEAAmE;IACnEO,QAAQE,IAAI,CAAC;QAAEC,KAAK;QAA0BC,OAAO;IAAU;IAE/D,IAAIL,gBAAgB;QAClBC,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAmBC,OAAOL;QAAe;IAC/D;IAEA,IAAID,mBAAmB;QACrBE,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAAsBC,OAAON;QAAkB;IACrE;IAEA,wEAAwE;IACxE,IAAIN,MAAM;QACRQ,QAAQE,IAAI,CAAC;YAAEC,KAAK;YAA8BC,OAAOZ;QAAK;IAChE;IAEA,kEAAkE;IAClE,KAAK,MAAMc,SAASf,WAAY;QAC9B,MAAMgB,IAAIP,QAAQQ,SAAS,CAAC,CAACC,IAAMA,EAAEN,GAAG,CAACO,WAAW,OAAOJ,MAAMH,GAAG,CAACO,WAAW;QAChF,IAAIH,KAAK,GAAG;YAACP,OAAO,CAACO,EAAE,GAAGD;QAAK,OAC1B;YAACN,QAAQE,IAAI,CAACI;QAAM;IAC3B;IAEA,OAAON;AACT"} \ No newline at end of file diff --git a/dist/modules/security/index.d.ts b/dist/modules/security/index.d.ts index 581d855..a6c339d 100644 --- a/dist/modules/security/index.d.ts +++ b/dist/modules/security/index.d.ts @@ -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'; diff --git a/dist/modules/security/index.js b/dist/modules/security/index.js index 8595f67..859e2e9 100644 --- a/dist/modules/security/index.js +++ b/dist/modules/security/index.js @@ -1,3 +1,4 @@ +export { buildCsp } from './buildCsp.js'; export { buildSecurityHeaders } from './buildSecurityHeaders.js'; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/modules/security/index.js.map b/dist/modules/security/index.js.map index 7bcc753..b827147 100644 --- a/dist/modules/security/index.js.map +++ b/dist/modules/security/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/docs/security.md b/docs/security.md index 52545d2..eb19973 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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`), 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ł 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ć > nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header) -> albo upewnij się, że CF przepuszcza nagłówki z origin. \ No newline at end of file +> 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. \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a3628a9..d195200 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/modules/security/buildCsp.ts b/src/modules/security/buildCsp.ts new file mode 100644 index 0000000..91426c9 --- /dev/null +++ b/src/modules/security/buildCsp.ts @@ -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> + /** 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 = { + '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 hijacking + 'frame-ancestors': ["'none'"], // clickjacking protection (replaces X-Frame-Options) + 'object-src': ["'none'"], // block / (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 } +} diff --git a/src/modules/security/buildSecurityHeaders.ts b/src/modules/security/buildSecurityHeaders.ts index 994c5d1..eb0b09b 100644 --- a/src/modules/security/buildSecurityHeaders.ts +++ b/src/modules/security/buildSecurityHeaders.ts @@ -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()) diff --git a/src/modules/security/index.ts b/src/modules/security/index.ts index dae0b94..9fde34a 100644 --- a/src/modules/security/index.ts +++ b/src/modules/security/index.ts @@ -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'