security headers, notifications, GDPR consent, masked fields
This commit is contained in:
Vendored
+9
-2
@@ -14,7 +14,7 @@ import { validateSubmission } from './validateSubmission.js';
|
||||
* which goes out over panelSmtpAdapter. Storing the submission is enough.
|
||||
*
|
||||
* server-only: touches the Turnstile secret.
|
||||
*/ export async function submitForm({ data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
|
||||
*/ export async function submitForm({ consentFieldName, data, formId, ip, maxPerMinute = 5, payload, turnstileToken }) {
|
||||
// 1. Rate limit — cheapest gate, drops a flood before any real work.
|
||||
if (maxPerMinute > 0 && ip) {
|
||||
if (!checkRateLimit({
|
||||
@@ -43,7 +43,7 @@ import { validateSubmission } from './validateSubmission.js';
|
||||
}
|
||||
// 3. Validate against the form's schema. A public endpoint can't trust the
|
||||
// shape of `data` — drop unknown keys, enforce required, cap length.
|
||||
const validation = await validateSubmission(payload, formId, data);
|
||||
const validation = await validateSubmission(payload, formId, data, consentFieldName);
|
||||
if (!validation.ok) {
|
||||
if (validation.reason === 'not_found') {
|
||||
return {
|
||||
@@ -51,6 +51,13 @@ import { validateSubmission } from './validateSubmission.js';
|
||||
success: false
|
||||
};
|
||||
}
|
||||
if (validation.reason === 'consent') {
|
||||
return {
|
||||
field: validation.field,
|
||||
reason: 'consent',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
reason: 'validation',
|
||||
success: false,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WA6BC"}
|
||||
{"version":3,"sources":["../../../src/modules/forms/types.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\n/**\n * Receives the collection's default fields and returns the final list — add,\n * remove, or reorder. Same shape the form-builder uses.\n */\nexport type FormsFieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\n/**\n * Overrides for a forms-related collection: replace the fields and/or any\n * other collection setting (admin, access, hooks…).\n */\nexport type FormsCollectionOverrides = {\n fields?: FormsFieldsOverride\n} & Partial<Omit<CollectionConfig, 'fields'>>\n\n/**\n * Forms configuration — mirrors the fields a client enables in the\n * form-builder plugin. Kept minimal; the plugin passes these through.\n */\nexport type FormsOption = {\n /**\n * Name of the checkbox field treated as a GDPR consent gate. A form field\n * with this name must be checked for submission to succeed — enforced\n * server-side in submitForm. Defaults to 'consent'.\n */\n consentFieldName?: string\n /** Field types available in the form builder. Sensible defaults applied. */\n fields?: {\n checkbox?: boolean\n email?: boolean\n message?: boolean\n number?: boolean\n payment?: boolean\n select?: boolean\n text?: boolean\n textarea?: boolean\n }\n /**\n * Override the forms collection. The plugin stays opinion-free about what a\n * form needs beyond its fields — a client that wants, say, a per-form\n * notification address adds it here:\n *\n * formOverrides: {\n * fields: ({ defaultFields }) => [\n * ...defaultFields,\n * { name: 'notificationEmail', type: 'email' },\n * ],\n * }\n */\n formOverrides?: FormsCollectionOverrides\n /** Override the form-submissions collection (same shape). */\n formSubmissionOverrides?: FormsCollectionOverrides\n /** Collections a form can redirect to (e.g. ['pages']). */\n redirectRelationships?: string[]\n}\n"],"names":[],"mappings":"AAgBA;;;CAGC,GACD,WAmCC"}
|
||||
+22
-2
@@ -12,6 +12,14 @@
|
||||
'g-recaptcha-response'
|
||||
]);
|
||||
/** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000;
|
||||
/**
|
||||
* Default name for a GDPR consent field. A checkbox with this name is treated
|
||||
* as a consent gate: it MUST be checked for the submission to go through,
|
||||
* enforced here server-side regardless of how the field was configured in the
|
||||
* panel (so an editor can't weaken it by forgetting `required` or, worse,
|
||||
* pre-ticking it with defaultValue: true — which GDPR forbids). Configurable
|
||||
* via FormsOption.consentFieldName.
|
||||
*/ const DEFAULT_CONSENT_FIELD = 'consent';
|
||||
/**
|
||||
* Checks submitted data against the form's own definition, rather than trusting
|
||||
* whatever arrived.
|
||||
@@ -24,7 +32,7 @@
|
||||
*
|
||||
* Returns the loaded form on success so the caller doesn't fetch it twice, and
|
||||
* a code + offending field on failure so the frontend can point at it.
|
||||
*/ export async function validateSubmission(payload, formId, data) {
|
||||
*/ export async function validateSubmission(payload, formId, data, consentFieldName = DEFAULT_CONSENT_FIELD) {
|
||||
let form;
|
||||
try {
|
||||
form = await payload.findByID({
|
||||
@@ -47,7 +55,19 @@
|
||||
for (const field of fields){
|
||||
const value = data[field.name];
|
||||
const isBlank = value == null || typeof value === 'string' && value.trim() === '' || value === false;
|
||||
if (field.required && isBlank) {
|
||||
// GDPR consent gate: a field matching the consent name must be truthy
|
||||
// (checked). Enforced independently of `required`, so it can't be weakened
|
||||
// in the panel. This is the one field where server-side enforcement is the
|
||||
// legal guarantee — the frontend can't bypass it, the editor can't misset it.
|
||||
if (field.name === consentFieldName) {
|
||||
if (value !== true) {
|
||||
return {
|
||||
field: field.name,
|
||||
ok: false,
|
||||
reason: 'consent'
|
||||
};
|
||||
}
|
||||
} else if (field.required && isBlank) {
|
||||
return {
|
||||
field: field.name,
|
||||
kind: 'required',
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Built-in English fallbacks, used per field when the Notifications global
|
||||
* leaves a text empty. Same philosophy as consent FALLBACK: the site works out
|
||||
* of the box, editors override per locale as needed.
|
||||
*/ export const NOTIFICATION_FALLBACK = {
|
||||
form: {
|
||||
success: 'Thank you — your message has been sent.',
|
||||
error: 'Something went wrong. Please try again later.',
|
||||
rateLimited: 'Too many attempts. Please wait a moment and try again.',
|
||||
turnstile: 'Captcha verification failed. Please try again.',
|
||||
validation: 'Please check the {field} field and try again.',
|
||||
consent: 'Please accept the privacy policy to continue.',
|
||||
notFound: 'This form is no longer available.'
|
||||
}
|
||||
};
|
||||
|
||||
//# sourceMappingURL=defaults.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/defaults.ts"],"sourcesContent":["import type { NotificationTexts } from './types.js'\n\n/**\n * Built-in English fallbacks, used per field when the Notifications global\n * leaves a text empty. Same philosophy as consent FALLBACK: the site works out\n * of the box, editors override per locale as needed.\n */\nexport const NOTIFICATION_FALLBACK: NotificationTexts = {\n form: {\n success: 'Thank you — your message has been sent.',\n error: 'Something went wrong. Please try again later.',\n rateLimited: 'Too many attempts. Please wait a moment and try again.',\n turnstile: 'Captcha verification failed. Please try again.',\n validation: 'Please check the {field} field and try again.',\n consent: 'Please accept the privacy policy to continue.',\n notFound: 'This form is no longer available.',\n },\n}\n"],"names":["NOTIFICATION_FALLBACK","form","success","error","rateLimited","turnstile","validation","consent","notFound"],"mappings":"AAEA;;;;CAIC,GACD,OAAO,MAAMA,wBAA2C;IACtDC,MAAM;QACJC,SAAS;QACTC,OAAO;QACPC,aAAa;QACbC,WAAW;QACXC,YAAY;QACZC,SAAS;QACTC,UAAU;IACZ;AACF,EAAC"}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getGlobal } from '../payload/index.js';
|
||||
import { NOTIFICATION_FALLBACK } from './defaults.js';
|
||||
/**
|
||||
* Resolves notification texts from the Notifications global, falling back to
|
||||
* English defaults per field. Mirrors getConsentTexts: one read, per-field
|
||||
* fallback, locale-aware. The frontend maps a submitForm result code to the
|
||||
* matching text and styles it however it likes (toast, inline, banner).
|
||||
*/ export async function getNotificationTexts({ locale, payload }) {
|
||||
const g = await getGlobal(payload, 'notifications', {
|
||||
locale
|
||||
});
|
||||
const f = g.form ?? {};
|
||||
const fb = NOTIFICATION_FALLBACK.form;
|
||||
return {
|
||||
form: {
|
||||
consent: f.consent || fb.consent,
|
||||
error: f.error || fb.error,
|
||||
notFound: f.notFound || fb.notFound,
|
||||
rateLimited: f.rateLimited || fb.rateLimited,
|
||||
success: f.success || fb.success,
|
||||
turnstile: f.turnstile || fb.turnstile,
|
||||
validation: f.validation || fb.validation
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
//# sourceMappingURL=getNotificationTexts.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/getNotificationTexts.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { NotificationsData, NotificationTexts } from './types.js'\n\nimport { getGlobal } from '../payload/index.js'\nimport { NOTIFICATION_FALLBACK } from './defaults.js'\n\ntype GetNotificationTextsArgs = {\n /** Active locale — selects the language variant of each text. */\n locale?: string\n payload: BasePayload\n}\n\n/**\n * Resolves notification texts from the Notifications global, falling back to\n * English defaults per field. Mirrors getConsentTexts: one read, per-field\n * fallback, locale-aware. The frontend maps a submitForm result code to the\n * matching text and styles it however it likes (toast, inline, banner).\n */\nexport async function getNotificationTexts({\n locale,\n payload,\n}: GetNotificationTextsArgs): Promise<NotificationTexts> {\n const g = await getGlobal<NotificationsData>(payload, 'notifications', { locale })\n\n const f = g.form ?? {}\n const fb = NOTIFICATION_FALLBACK.form\n\n return {\n form: {\n consent: f.consent || fb.consent,\n error: f.error || fb.error,\n notFound: f.notFound || fb.notFound,\n rateLimited: f.rateLimited || fb.rateLimited,\n success: f.success || fb.success,\n turnstile: f.turnstile || fb.turnstile,\n validation: f.validation || fb.validation,\n },\n }\n}\n"],"names":["getGlobal","NOTIFICATION_FALLBACK","getNotificationTexts","locale","payload","g","f","form","fb","consent","error","notFound","rateLimited","success","turnstile","validation"],"mappings":"AAIA,SAASA,SAAS,QAAQ,sBAAqB;AAC/C,SAASC,qBAAqB,QAAQ,gBAAe;AAQrD;;;;;CAKC,GACD,OAAO,eAAeC,qBAAqB,EACzCC,MAAM,EACNC,OAAO,EACkB;IACzB,MAAMC,IAAI,MAAML,UAA6BI,SAAS,iBAAiB;QAAED;IAAO;IAEhF,MAAMG,IAAID,EAAEE,IAAI,IAAI,CAAC;IACrB,MAAMC,KAAKP,sBAAsBM,IAAI;IAErC,OAAO;QACLA,MAAM;YACJE,SAASH,EAAEG,OAAO,IAAID,GAAGC,OAAO;YAChCC,OAAOJ,EAAEI,KAAK,IAAIF,GAAGE,KAAK;YAC1BC,UAAUL,EAAEK,QAAQ,IAAIH,GAAGG,QAAQ;YACnCC,aAAaN,EAAEM,WAAW,IAAIJ,GAAGI,WAAW;YAC5CC,SAASP,EAAEO,OAAO,IAAIL,GAAGK,OAAO;YAChCC,WAAWR,EAAEQ,SAAS,IAAIN,GAAGM,SAAS;YACtCC,YAAYT,EAAES,UAAU,IAAIP,GAAGO,UAAU;QAC3C;IACF;AACF"}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export { NOTIFICATION_FALLBACK } from './defaults.js';
|
||||
export { getNotificationTexts } from './getNotificationTexts.js';
|
||||
export { resolveFormMessage } from './resolveFormMessage.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/index.ts"],"sourcesContent":["export { NOTIFICATION_FALLBACK } from './defaults.js'\nexport { getNotificationTexts } from './getNotificationTexts.js'\nexport { resolveFormMessage } from './resolveFormMessage.js'\nexport type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js'\n"],"names":["NOTIFICATION_FALLBACK","getNotificationTexts","resolveFormMessage"],"mappings":"AAAA,SAASA,qBAAqB,QAAQ,gBAAe;AACrD,SAASC,oBAAoB,QAAQ,4BAA2B;AAChE,SAASC,kBAAkB,QAAQ,0BAAyB"}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Maps a submitForm result to the user-facing message, interpolating {field}
|
||||
* for validation errors. This is the bridge the frontend uses: it gets a result
|
||||
* code from submitForm and the resolved texts from getNotificationTexts, and
|
||||
* this turns them into one string to display. Keeping the mapping here means the
|
||||
* frontend never hard-codes messages or knows about result codes.
|
||||
*
|
||||
* Never surfaces raw backend/exception detail — 'error' maps to a friendly
|
||||
* generic message, not the thrown error's text (which could leak internals).
|
||||
*/ export function resolveFormMessage(result, texts) {
|
||||
if (result.success) {
|
||||
return texts.success;
|
||||
}
|
||||
switch(result.reason){
|
||||
case 'consent':
|
||||
return texts.consent;
|
||||
case 'not_found':
|
||||
return texts.notFound;
|
||||
case 'rate_limited':
|
||||
return texts.rateLimited;
|
||||
case 'turnstile':
|
||||
return texts.turnstile;
|
||||
case 'validation':
|
||||
{
|
||||
// Interpolate {field} with the offending field name when present.
|
||||
const field = 'field' in result && result.field ? result.field : '';
|
||||
return texts.validation.replace('{field}', field);
|
||||
}
|
||||
case 'error':
|
||||
default:
|
||||
return texts.error;
|
||||
}
|
||||
}
|
||||
|
||||
//# sourceMappingURL=resolveFormMessage.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/resolveFormMessage.ts"],"sourcesContent":["import type { SubmitFormResult } from '../forms/index.js'\nimport type { FormNotificationTexts } from './types.js'\n\n/**\n * Maps a submitForm result to the user-facing message, interpolating {field}\n * for validation errors. This is the bridge the frontend uses: it gets a result\n * code from submitForm and the resolved texts from getNotificationTexts, and\n * this turns them into one string to display. Keeping the mapping here means the\n * frontend never hard-codes messages or knows about result codes.\n *\n * Never surfaces raw backend/exception detail — 'error' maps to a friendly\n * generic message, not the thrown error's text (which could leak internals).\n */\nexport function resolveFormMessage(result: SubmitFormResult, texts: FormNotificationTexts): string {\n if (result.success) {return texts.success}\n\n switch (result.reason) {\n case 'consent':\n return texts.consent\n case 'not_found':\n return texts.notFound\n case 'rate_limited':\n return texts.rateLimited\n case 'turnstile':\n return texts.turnstile\n case 'validation': {\n // Interpolate {field} with the offending field name when present.\n const field = 'field' in result && result.field ? result.field : ''\n return texts.validation.replace('{field}', field)\n }\n case 'error':\n default:\n return texts.error\n }\n}\n"],"names":["resolveFormMessage","result","texts","success","reason","consent","notFound","rateLimited","turnstile","field","validation","replace","error"],"mappings":"AAGA;;;;;;;;;CASC,GACD,OAAO,SAASA,mBAAmBC,MAAwB,EAAEC,KAA4B;IACvF,IAAID,OAAOE,OAAO,EAAE;QAAC,OAAOD,MAAMC,OAAO;IAAA;IAEzC,OAAQF,OAAOG,MAAM;QACnB,KAAK;YACH,OAAOF,MAAMG,OAAO;QACtB,KAAK;YACH,OAAOH,MAAMI,QAAQ;QACvB,KAAK;YACH,OAAOJ,MAAMK,WAAW;QAC1B,KAAK;YACH,OAAOL,MAAMM,SAAS;QACxB,KAAK;YAAc;gBACjB,kEAAkE;gBAClE,MAAMC,QAAQ,WAAWR,UAAUA,OAAOQ,KAAK,GAAGR,OAAOQ,KAAK,GAAG;gBACjE,OAAOP,MAAMQ,UAAU,CAACC,OAAO,CAAC,WAAWF;YAC7C;QACA,KAAK;QACL;YACE,OAAOP,MAAMU,KAAK;IACtB;AACF"}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Resolved notification texts, ready for the frontend. Grouped per context;
|
||||
* `form` maps submitForm result codes to user-facing messages.
|
||||
*/ /** Raw shape read from the Notifications global (all fields optional). */ export { };
|
||||
|
||||
//# sourceMappingURL=types.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"sources":["../../../src/modules/notifications/types.ts"],"sourcesContent":["/**\n * Resolved notification texts, ready for the frontend. Grouped per context;\n * `form` maps submitForm result codes to user-facing messages.\n */\nexport type FormNotificationTexts = {\n success: string\n error: string\n rateLimited: string\n turnstile: string\n /** May contain the {field} placeholder — resolve with resolveValidationText. */\n validation: string\n /** Shown when a required GDPR consent checkbox was left unchecked. */\n consent: string\n notFound: string\n}\n\nexport type NotificationTexts = {\n form: FormNotificationTexts\n}\n\n/** Raw shape read from the Notifications global (all fields optional). */\nexport type NotificationsData = {\n form?: Partial<FormNotificationTexts>\n}\n"],"names":[],"mappings":"AAAA;;;CAGC,GAiBD,wEAAwE,GACxE,WAEC"}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A single HTTP header, in the shape Next.js next.config headers() expects.
|
||||
*/
|
||||
export type SecurityHeader = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
export type BuildSecurityHeadersArgs = {
|
||||
/**
|
||||
* Extra headers to append or override. Same-key entries replace the default,
|
||||
* so you can e.g. add your project's Content-Security-Policy here — CSP is
|
||||
* intentionally NOT a default because it depends on the project's own
|
||||
* domains (scripts, images, fonts, analytics). Keep CSP in your project.
|
||||
*/
|
||||
additional?: SecurityHeader[];
|
||||
/**
|
||||
* X-Frame-Options value. 'DENY' (default) blocks all framing; 'SAMEORIGIN'
|
||||
* allows same-origin framing. Note: CSP frame-ancestors supersedes this in
|
||||
* modern browsers, but X-Frame-Options is kept for older ones. Set to null
|
||||
* to omit (e.g. if you set frame-ancestors in your project CSP).
|
||||
*/
|
||||
frameOptions?: 'DENY' | 'SAMEORIGIN' | null;
|
||||
/**
|
||||
* Enable HSTS (Strict-Transport-Security). Only takes effect over HTTPS, and
|
||||
* tells browsers to force HTTPS for `maxAge` seconds. Default true. Turn OFF
|
||||
* in local/dev over plain HTTP, or you may lock the browser to https on
|
||||
* localhost. Set the env guard in your next.config (see docs).
|
||||
*/
|
||||
hsts?: boolean;
|
||||
/** Add includeSubDomains to HSTS. Default true. */
|
||||
hstsIncludeSubDomains?: boolean;
|
||||
/** HSTS max-age in seconds. Default 63072000 (2 years), the common baseline. */
|
||||
hstsMaxAge?: number;
|
||||
/** Add preload to HSTS (only if you'll submit to the preload list). Default false. */
|
||||
hstsPreload?: boolean;
|
||||
/**
|
||||
* Permissions-Policy. Default disables camera, microphone, geolocation. Pass
|
||||
* your own string to override, or null to omit.
|
||||
*/
|
||||
permissionsPolicy?: null | string;
|
||||
/** Referrer-Policy. Default 'strict-origin-when-cross-origin' (browser default, explicit). */
|
||||
referrerPolicy?: null | string;
|
||||
};
|
||||
/**
|
||||
* Builds the generic, project-independent security headers every site should
|
||||
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
|
||||
* Permissions-Policy. These are identical across projects, so the plugin owns
|
||||
* the boilerplate; the client spreads the result into next.config's headers().
|
||||
*
|
||||
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
|
||||
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
|
||||
* be generic without being either too loose (useless) or too strict (breaks the
|
||||
* site). Add your project's CSP via `additional`.
|
||||
*
|
||||
* @example
|
||||
* // next.config.ts
|
||||
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
* const securityHeaders = buildSecurityHeaders({
|
||||
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
|
||||
* additional: [
|
||||
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
* ],
|
||||
* })
|
||||
* const nextConfig = {
|
||||
* async headers() {
|
||||
* return [{ source: '/:path*', headers: securityHeaders }]
|
||||
* },
|
||||
* }
|
||||
*/
|
||||
export declare function buildSecurityHeaders(args?: BuildSecurityHeadersArgs): SecurityHeader[];
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* A single HTTP header, in the shape Next.js next.config headers() expects.
|
||||
*/ /**
|
||||
* Builds the generic, project-independent security headers every site should
|
||||
* send: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
|
||||
* Permissions-Policy. These are identical across projects, so the plugin owns
|
||||
* the boilerplate; the client spreads the result into next.config's headers().
|
||||
*
|
||||
* Content-Security-Policy is deliberately excluded: a useful CSP enumerates the
|
||||
* exact domains a project loads from (its CDN, analytics, embeds), so it can't
|
||||
* be generic without being either too loose (useless) or too strict (breaks the
|
||||
* site). Add your project's CSP via `additional`.
|
||||
*
|
||||
* @example
|
||||
* // next.config.ts
|
||||
* import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
* const securityHeaders = buildSecurityHeaders({
|
||||
* hsts: process.env.NODE_ENV === 'production', // off in dev over http
|
||||
* additional: [
|
||||
* { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
|
||||
* ],
|
||||
* })
|
||||
* const nextConfig = {
|
||||
* async headers() {
|
||||
* return [{ source: '/:path*', headers: securityHeaders }]
|
||||
* },
|
||||
* }
|
||||
*/ 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 headers = [];
|
||||
if (hsts) {
|
||||
const parts = [
|
||||
`max-age=${hstsMaxAge}`
|
||||
];
|
||||
if (hstsIncludeSubDomains) {
|
||||
parts.push('includeSubDomains');
|
||||
}
|
||||
if (hstsPreload) {
|
||||
parts.push('preload');
|
||||
}
|
||||
headers.push({
|
||||
key: 'Strict-Transport-Security',
|
||||
value: parts.join('; ')
|
||||
});
|
||||
}
|
||||
if (frameOptions) {
|
||||
headers.push({
|
||||
key: 'X-Frame-Options',
|
||||
value: frameOptions
|
||||
});
|
||||
}
|
||||
// Prevents MIME-type sniffing — always safe, no project specifics.
|
||||
headers.push({
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff'
|
||||
});
|
||||
if (referrerPolicy) {
|
||||
headers.push({
|
||||
key: 'Referrer-Policy',
|
||||
value: referrerPolicy
|
||||
});
|
||||
}
|
||||
if (permissionsPolicy) {
|
||||
headers.push({
|
||||
key: 'Permissions-Policy',
|
||||
value: permissionsPolicy
|
||||
});
|
||||
}
|
||||
// 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());
|
||||
if (i >= 0) {
|
||||
headers[i] = extra;
|
||||
} else {
|
||||
headers.push(extra);
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=buildSecurityHeaders.js.map
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||
export type { BuildSecurityHeadersArgs, SecurityHeader } from './buildSecurityHeaders.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
export { buildSecurityHeaders } from './buildSecurityHeaders.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +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"}
|
||||
Reference in New Issue
Block a user