added validation for forms, sensitive text fields have been masked

This commit is contained in:
2026-08-21 17:15:02 +02:00
parent 5aa66ff37a
commit 2215766940
19 changed files with 325 additions and 4 deletions
+17 -1
View File
@@ -7,6 +7,11 @@ import { checkRateLimit } from './rateLimit.js'
import { validateSubmission } from './validateSubmission.js'
export type SubmitFormArgs = {
/**
* Name of the GDPR consent checkbox. A field with this name must be checked
* for the submission to succeed (enforced server-side). Defaults to 'consent'.
*/
consentFieldName?: string
/** Submitted field data — shape matches the form's fields. */
data: Record<string, unknown>
/** Form-builder form ID this submission belongs to. */
@@ -37,6 +42,7 @@ export type SubmitFormArgs = {
* `field` and `kind` narrow it down when a specific field is
* at fault (absent for whole-payload problems like unknown keys)
* - `not_found` — no form with this id
* - `consent` — a required GDPR consent checkbox was left unchecked
* - `error` — persistence failed unexpectedly
*/
export type SubmitFailure =
@@ -48,6 +54,12 @@ export type SubmitFailure =
reason: 'validation'
success: false
}
| {
field?: string
/** A GDPR consent field existed on the form but wasn't checked. */
reason: 'consent'
success: false
}
| { reason: 'error'; success: false }
| { reason: 'not_found'; success: false }
| { reason: 'rate_limited'; success: false }
@@ -69,6 +81,7 @@ export type SubmitFormResult = { submissionId: number | string; success: true }
* server-only: touches the Turnstile secret.
*/
export async function submitForm({
consentFieldName,
data,
formId,
ip,
@@ -93,11 +106,14 @@ export async function submitForm({
// 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 { reason: 'not_found', success: false }
}
if (validation.reason === 'consent') {
return { field: validation.field, reason: 'consent', success: false }
}
return {
reason: 'validation',
success: false,
+6
View File
@@ -19,6 +19,12 @@ export type FormsCollectionOverrides = {
* form-builder plugin. Kept minimal; the plugin passes these through.
*/
export type FormsOption = {
/**
* Name of the checkbox field treated as a GDPR consent gate. A form field
* with this name must be checked for submission to succeed — enforced
* server-side in submitForm. Defaults to 'consent'.
*/
consentFieldName?: string
/** Field types available in the form builder. Sensible defaults applied. */
fields?: {
checkbox?: boolean
+21 -1
View File
@@ -29,6 +29,7 @@ export type FormValidationResult =
reason: 'invalid'
}
| { cleaned: Record<string, unknown>; form: FormDoc; ok: true }
| { field: string; ok: false; reason: 'consent' }
| { ok: false; reason: 'not_found' }
/** Field block types that don't carry a submittable value. */
@@ -46,6 +47,16 @@ const CAPTCHA_KEYS = new Set(['cf-turnstile-response', '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.
@@ -63,6 +74,7 @@ export async function validateSubmission(
payload: BasePayload,
formId: string,
data: Record<string, unknown>,
consentFieldName: string = DEFAULT_CONSENT_FIELD,
): Promise<FormValidationResult> {
let form: FormDoc
try {
@@ -88,7 +100,15 @@ export async function validateSubmission(
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', ok: false, reason: 'invalid' }
}
+18
View File
@@ -0,0 +1,18 @@
import type { NotificationTexts } from './types.js'
/**
* 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: NotificationTexts = {
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.',
},
}
@@ -0,0 +1,40 @@
import type { BasePayload } from 'payload'
import type { NotificationsData, NotificationTexts } from './types.js'
import { getGlobal } from '../payload/index.js'
import { NOTIFICATION_FALLBACK } from './defaults.js'
type GetNotificationTextsArgs = {
/** Active locale — selects the language variant of each text. */
locale?: string
payload: BasePayload
}
/**
* 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,
}: GetNotificationTextsArgs): Promise<NotificationTexts> {
const g = await getGlobal<NotificationsData>(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,
},
}
}
+4
View File
@@ -0,0 +1,4 @@
export { NOTIFICATION_FALLBACK } from './defaults.js'
export { getNotificationTexts } from './getNotificationTexts.js'
export { resolveFormMessage } from './resolveFormMessage.js'
export type { FormNotificationTexts, NotificationsData, NotificationTexts } from './types.js'
@@ -0,0 +1,35 @@
import type { SubmitFormResult } from '../forms/index.js'
import type { FormNotificationTexts } from './types.js'
/**
* 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: SubmitFormResult, texts: FormNotificationTexts): string {
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
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Resolved notification texts, ready for the frontend. Grouped per context;
* `form` maps submitForm result codes to user-facing messages.
*/
export type FormNotificationTexts = {
success: string
error: string
rateLimited: string
turnstile: string
/** May contain the {field} placeholder — resolve with resolveValidationText. */
validation: string
/** Shown when a required GDPR consent checkbox was left unchecked. */
consent: string
notFound: string
}
export type NotificationTexts = {
form: FormNotificationTexts
}
/** Raw shape read from the Notifications global (all fields optional). */
export type NotificationsData = {
form?: Partial<FormNotificationTexts>
}