diff --git a/src/modules/forms/validateSubmission.ts b/src/modules/forms/validateSubmission.ts index d35feba..a19a228 100644 --- a/src/modules/forms/validateSubmission.ts +++ b/src/modules/forms/validateSubmission.ts @@ -34,6 +34,15 @@ export type FormValidationResult = /** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set(['message']) +/** + * Keys injected by the captcha widget itself, not by the form definition. + * Cloudflare Turnstile adds a hidden after + * a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the + * plugin drives Turnstile end-to-end, these are legitimate artifacts — they + * must not count as "unknown fields" and trip the anti-tampering check. + */ +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 @@ -95,8 +104,10 @@ export async function validateSubmission( } // Reject outright if the payload carried keys the form doesn't define — a - // sign the request wasn't produced by the rendered form. - const unknownKeys = Object.keys(data).filter((k) => !known.has(k)) + // sign the request wasn't produced by the rendered form. Captcha keys are + // exempt: the widget injects them into the rendered form, so they're expected, + // not tampering. + const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k)) if (unknownKeys.length > 0) { return { kind: 'unknown_fields', ok: false, reason: 'invalid' } }