added validation for forms, sensitive text fields have been masked
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user