diff --git a/dist/modules/forms/validateSubmission.js b/dist/modules/forms/validateSubmission.js index 97aae90..3095f74 100644 --- a/dist/modules/forms/validateSubmission.js +++ b/dist/modules/forms/validateSubmission.js @@ -1,6 +1,16 @@ /** 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; /** * Checks submitted data against the form's own definition, rather than trusting @@ -60,8 +70,10 @@ } } // 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', diff --git a/dist/modules/forms/validateSubmission.js.map b/dist/modules/forms/validateSubmission.js.map index 7717af9..22b5b14 100644 --- a/dist/modules/forms/validateSubmission.js.map +++ b/dist/modules/forms/validateSubmission.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/forms/validateSubmission.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\n/** A form-builder field, trimmed to what validation needs. */\ntype FormField = {\n blockType?: string\n label?: string\n name?: string\n required?: boolean | null\n}\n\ntype FormDoc = {\n fields?: FormField[]\n id: number | string\n /** Per-form notification address, when the client added the field. */\n notificationEmail?: string\n title?: string\n}\n\n/**\n * Validation outcome — codes, not user-facing strings. The frontend turns these\n * into its own copy (see SubmitFailure in submitForm).\n */\nexport type FormValidationResult =\n | {\n /** Offending field, when a single field is at fault. */\n field?: string\n kind: 'required' | 'too_long' | 'unknown_fields'\n ok: false\n reason: 'invalid'\n }\n | { cleaned: Record; form: FormDoc; ok: true }\n | { ok: false; reason: 'not_found' }\n\n/** Field block types that don't carry a submittable value. */\nconst NON_DATA_BLOCKS = new Set(['message'])\n\n/** Hard ceiling on a single field's length, independent of the form config. */\nconst MAX_FIELD_LENGTH = 5000\n\n/**\n * Checks submitted data against the form's own definition, rather than trusting\n * whatever arrived.\n *\n * The server action is a public endpoint: a caller can skip the rendered form\n * and post arbitrary keys. Without this, unknown fields would be stored,\n * required fields could be missing, and an oversized value could sail through.\n * So we load the form, keep only keys that are real fields, reject when a\n * required one is blank, and cap length.\n *\n * Returns the loaded form on success so the caller doesn't fetch it twice, and\n * a code + offending field on failure so the frontend can point at it.\n */\nexport async function validateSubmission(\n payload: BasePayload,\n formId: string,\n data: Record,\n): Promise {\n let form: FormDoc\n try {\n form = (await payload.findByID({\n id: formId,\n collection: 'forms',\n depth: 0,\n })) as FormDoc\n } catch {\n return { ok: false, reason: 'not_found' }\n }\n\n const fields = (form.fields ?? []).filter(\n (f): f is { name: string } & FormField =>\n typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),\n )\n const known = new Map(fields.map((f) => [f.name, f]))\n\n const cleaned: Record = {}\n\n for (const field of fields) {\n const value = data[field.name]\n const isBlank =\n value == null || (typeof value === 'string' && value.trim() === '') || value === false\n\n if (field.required && isBlank) {\n return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }\n }\n\n if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {\n return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }\n }\n\n // Only carry through keys that belong to the form — unknown keys from a\n // hand-crafted request are dropped, not stored.\n if (value !== undefined) {\n cleaned[field.name] = value\n }\n }\n\n // Reject outright if the payload carried keys the form doesn't define — a\n // sign the request wasn't produced by the rendered form.\n const unknownKeys = Object.keys(data).filter((k) => !known.has(k))\n if (unknownKeys.length > 0) {\n return { kind: 'unknown_fields', ok: false, reason: 'invalid' }\n }\n\n return { cleaned, form, ok: true }\n}\n"],"names":["NON_DATA_BLOCKS","Set","MAX_FIELD_LENGTH","validateSubmission","payload","formId","data","form","findByID","id","collection","depth","ok","reason","fields","filter","f","name","has","blockType","known","Map","map","cleaned","field","value","isBlank","trim","required","kind","length","undefined","unknownKeys","Object","keys","k"],"mappings":"AAiCA,4DAA4D,GAC5D,MAAMA,kBAAkB,IAAIC,IAAI;IAAC;CAAU;AAE3C,6EAA6E,GAC7E,MAAMC,mBAAmB;AAEzB;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,mBACpBC,OAAoB,EACpBC,MAAc,EACdC,IAA6B;IAE7B,IAAIC;IACJ,IAAI;QACFA,OAAQ,MAAMH,QAAQI,QAAQ,CAAC;YAC7BC,IAAIJ;YACJK,YAAY;YACZC,OAAO;QACT;IACF,EAAE,OAAM;QACN,OAAO;YAAEC,IAAI;YAAOC,QAAQ;QAAY;IAC1C;IAEA,MAAMC,SAAS,AAACP,CAAAA,KAAKO,MAAM,IAAI,EAAE,AAAD,EAAGC,MAAM,CACvC,CAACC,IACC,OAAOA,EAAEC,IAAI,KAAK,YAAY,CAACjB,gBAAgBkB,GAAG,CAACF,EAAEG,SAAS,IAAI;IAEtE,MAAMC,QAAQ,IAAIC,IAAIP,OAAOQ,GAAG,CAAC,CAACN,IAAM;YAACA,EAAEC,IAAI;YAAED;SAAE;IAEnD,MAAMO,UAAmC,CAAC;IAE1C,KAAK,MAAMC,SAASV,OAAQ;QAC1B,MAAMW,QAAQnB,IAAI,CAACkB,MAAMP,IAAI,CAAC;QAC9B,MAAMS,UACJD,SAAS,QAAS,OAAOA,UAAU,YAAYA,MAAME,IAAI,OAAO,MAAOF,UAAU;QAEnF,IAAID,MAAMI,QAAQ,IAAIF,SAAS;YAC7B,OAAO;gBAAEF,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,IAAI,OAAOY,UAAU,YAAYA,MAAMK,MAAM,GAAG5B,kBAAkB;YAChE,OAAO;gBAAEsB,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,wEAAwE;QACxE,gDAAgD;QAChD,IAAIY,UAAUM,WAAW;YACvBR,OAAO,CAACC,MAAMP,IAAI,CAAC,GAAGQ;QACxB;IACF;IAEA,0EAA0E;IAC1E,yDAAyD;IACzD,MAAMO,cAAcC,OAAOC,IAAI,CAAC5B,MAAMS,MAAM,CAAC,CAACoB,IAAM,CAACf,MAAMF,GAAG,CAACiB;IAC/D,IAAIH,YAAYF,MAAM,GAAG,GAAG;QAC1B,OAAO;YAAED,MAAM;YAAkBjB,IAAI;YAAOC,QAAQ;QAAU;IAChE;IAEA,OAAO;QAAEU;QAAShB;QAAMK,IAAI;IAAK;AACnC"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/forms/validateSubmission.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\n/** A form-builder field, trimmed to what validation needs. */\ntype FormField = {\n blockType?: string\n label?: string\n name?: string\n required?: boolean | null\n}\n\ntype FormDoc = {\n fields?: FormField[]\n id: number | string\n /** Per-form notification address, when the client added the field. */\n notificationEmail?: string\n title?: string\n}\n\n/**\n * Validation outcome — codes, not user-facing strings. The frontend turns these\n * into its own copy (see SubmitFailure in submitForm).\n */\nexport type FormValidationResult =\n | {\n /** Offending field, when a single field is at fault. */\n field?: string\n kind: 'required' | 'too_long' | 'unknown_fields'\n ok: false\n reason: 'invalid'\n }\n | { cleaned: Record; form: FormDoc; ok: true }\n | { ok: false; reason: 'not_found' }\n\n/** Field block types that don't carry a submittable value. */\nconst NON_DATA_BLOCKS = new Set(['message'])\n\n/**\n * Keys injected by the captcha widget itself, not by the form definition.\n * Cloudflare Turnstile adds a hidden after\n * a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the\n * plugin drives Turnstile end-to-end, these are legitimate artifacts — they\n * must not count as \"unknown fields\" and trip the anti-tampering check.\n */\nconst CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response'])\n\n/** Hard ceiling on a single field's length, independent of the form config. */\nconst MAX_FIELD_LENGTH = 5000\n\n/**\n * Checks submitted data against the form's own definition, rather than trusting\n * whatever arrived.\n *\n * The server action is a public endpoint: a caller can skip the rendered form\n * and post arbitrary keys. Without this, unknown fields would be stored,\n * required fields could be missing, and an oversized value could sail through.\n * So we load the form, keep only keys that are real fields, reject when a\n * required one is blank, and cap length.\n *\n * Returns the loaded form on success so the caller doesn't fetch it twice, and\n * a code + offending field on failure so the frontend can point at it.\n */\nexport async function validateSubmission(\n payload: BasePayload,\n formId: string,\n data: Record,\n): Promise {\n let form: FormDoc\n try {\n form = (await payload.findByID({\n id: formId,\n collection: 'forms',\n depth: 0,\n })) as FormDoc\n } catch {\n return { ok: false, reason: 'not_found' }\n }\n\n const fields = (form.fields ?? []).filter(\n (f): f is { name: string } & FormField =>\n typeof f.name === 'string' && !NON_DATA_BLOCKS.has(f.blockType ?? ''),\n )\n const known = new Map(fields.map((f) => [f.name, f]))\n\n const cleaned: Record = {}\n\n for (const field of fields) {\n const value = data[field.name]\n const isBlank =\n value == null || (typeof value === 'string' && value.trim() === '') || value === false\n\n if (field.required && isBlank) {\n return { field: field.name, kind: 'required', ok: false, reason: 'invalid' }\n }\n\n if (typeof value === 'string' && value.length > MAX_FIELD_LENGTH) {\n return { field: field.name, kind: 'too_long', ok: false, reason: 'invalid' }\n }\n\n // Only carry through keys that belong to the form — unknown keys from a\n // hand-crafted request are dropped, not stored.\n if (value !== undefined) {\n cleaned[field.name] = value\n }\n }\n\n // Reject outright if the payload carried keys the form doesn't define — a\n // sign the request wasn't produced by the rendered form. Captcha keys are\n // exempt: the widget injects them into the rendered form, so they're expected,\n // not tampering.\n const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k))\n if (unknownKeys.length > 0) {\n return { kind: 'unknown_fields', ok: false, reason: 'invalid' }\n }\n\n return { cleaned, form, ok: true }\n}\n"],"names":["NON_DATA_BLOCKS","Set","CAPTCHA_KEYS","MAX_FIELD_LENGTH","validateSubmission","payload","formId","data","form","findByID","id","collection","depth","ok","reason","fields","filter","f","name","has","blockType","known","Map","map","cleaned","field","value","isBlank","trim","required","kind","length","undefined","unknownKeys","Object","keys","k"],"mappings":"AAiCA,4DAA4D,GAC5D,MAAMA,kBAAkB,IAAIC,IAAI;IAAC;CAAU;AAE3C;;;;;;CAMC,GACD,MAAMC,eAAe,IAAID,IAAI;IAAC;IAAyB;CAAuB;AAE9E,6EAA6E,GAC7E,MAAME,mBAAmB;AAEzB;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,mBACpBC,OAAoB,EACpBC,MAAc,EACdC,IAA6B;IAE7B,IAAIC;IACJ,IAAI;QACFA,OAAQ,MAAMH,QAAQI,QAAQ,CAAC;YAC7BC,IAAIJ;YACJK,YAAY;YACZC,OAAO;QACT;IACF,EAAE,OAAM;QACN,OAAO;YAAEC,IAAI;YAAOC,QAAQ;QAAY;IAC1C;IAEA,MAAMC,SAAS,AAACP,CAAAA,KAAKO,MAAM,IAAI,EAAE,AAAD,EAAGC,MAAM,CACvC,CAACC,IACC,OAAOA,EAAEC,IAAI,KAAK,YAAY,CAAClB,gBAAgBmB,GAAG,CAACF,EAAEG,SAAS,IAAI;IAEtE,MAAMC,QAAQ,IAAIC,IAAIP,OAAOQ,GAAG,CAAC,CAACN,IAAM;YAACA,EAAEC,IAAI;YAAED;SAAE;IAEnD,MAAMO,UAAmC,CAAC;IAE1C,KAAK,MAAMC,SAASV,OAAQ;QAC1B,MAAMW,QAAQnB,IAAI,CAACkB,MAAMP,IAAI,CAAC;QAC9B,MAAMS,UACJD,SAAS,QAAS,OAAOA,UAAU,YAAYA,MAAME,IAAI,OAAO,MAAOF,UAAU;QAEnF,IAAID,MAAMI,QAAQ,IAAIF,SAAS;YAC7B,OAAO;gBAAEF,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,IAAI,OAAOY,UAAU,YAAYA,MAAMK,MAAM,GAAG5B,kBAAkB;YAChE,OAAO;gBAAEsB,OAAOA,MAAMP,IAAI;gBAAEY,MAAM;gBAAYjB,IAAI;gBAAOC,QAAQ;YAAU;QAC7E;QAEA,wEAAwE;QACxE,gDAAgD;QAChD,IAAIY,UAAUM,WAAW;YACvBR,OAAO,CAACC,MAAMP,IAAI,CAAC,GAAGQ;QACxB;IACF;IAEA,0EAA0E;IAC1E,0EAA0E;IAC1E,+EAA+E;IAC/E,iBAAiB;IACjB,MAAMO,cAAcC,OAAOC,IAAI,CAAC5B,MAAMS,MAAM,CAAC,CAACoB,IAAM,CAACf,MAAMF,GAAG,CAACiB,MAAM,CAAClC,aAAaiB,GAAG,CAACiB;IACvF,IAAIH,YAAYF,MAAM,GAAG,GAAG;QAC1B,OAAO;YAAED,MAAM;YAAkBjB,IAAI;YAAOC,QAAQ;QAAU;IAChE;IAEA,OAAO;QAAEU;QAAShB;QAAMK,IAAI;IAAK;AACnC"} \ No newline at end of file