From c9b610590eac10f14395b4516803e3d95a35165c Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 30 Oct 2026 11:17:52 -0700 Subject: [PATCH] Fail closed in verifyChecksum; drop the Contact.get fallback If Contact.validateChecksum threw for any reason, verifyChecksum retried with Contact.get plus an unknown `checksum` param. Our API key is privileged, so if Civi ever ignored that param the probe would resolve the contact and accept any checksum. Treat errors as invalid instead, and reject non-positive/non-integer contact ids up front. --- app/api/health/route.ts | 7 +++---- lib/civicrm.ts | 25 +++++++++++-------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index ae9b67a..810ad40 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -245,9 +245,8 @@ async function probeStageOptionGroup(): Promise { async function probeChecksumApi(): Promise { // Tries to call validateChecksum with a deliberately invalid checksum. // If the API exists, we get back {valid: false} or similar — that's - // success for THIS check (the API responded). If the API doesn't exist - // we fall back to a Contact.get probe in verifyChecksum, which will - // also fail gracefully. + // success for THIS check (the API responded). If the API doesn't exist, + // verifyChecksum fails closed and every form link will be rejected. try { await civi("Contact", "validateChecksum", { contactId: 1, @@ -266,7 +265,7 @@ async function probeChecksumApi(): Promise { return { check: "contact_validate_checksum_api", ok: false, - detail: `validateChecksum action not present in this Civi version. The verifyChecksum() helper falls back to a Contact.get probe with cs param. Detail: ${msg}`, + detail: `validateChecksum action not present in this Civi version. verifyChecksum() fails closed, so every form link will be rejected until Civi is upgraded. Detail: ${msg}`, }; } return { diff --git a/lib/civicrm.ts b/lib/civicrm.ts index 02b7d4c..28e7439 100644 --- a/lib/civicrm.ts +++ b/lib/civicrm.ts @@ -297,32 +297,29 @@ export async function civiMultipart( } /** - * Validate a contact checksum (cid + cs) against CiviCRM. + * Validate a contact checksum (cid + cs) against CiviCRM via APIv4 + * Contact.validateChecksum. * - * APIv4 exposes Contact.validateChecksum in newer Civi versions. For older - * versions you may need to call Contact.get with the cs param and verify - * the contact resolves. We use validateChecksum here and fall back to a - * Contact.get probe if it returns a "missing API" error. + * Fails closed: any API error is treated as an invalid checksum. There is + * deliberately no fallback — our API key is privileged, so any probe that + * merely checks "does the contact resolve" would accept every checksum. + * /api/health confirms validateChecksum is available on the install. */ export async function verifyChecksum(cid: string, cs: string): Promise { if (isStubMode()) { // STUB: any non-empty cs is "valid" so the UI can be exercised locally. return Boolean(cid && cs); } + const contactId = Number(cid); + if (!Number.isSafeInteger(contactId) || contactId <= 0) return false; try { const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", { - contactId: Number(cid), + contactId, checksum: cs, }); return Boolean(res.values?.[0]?.valid); } catch (e) { - // Fallback: try Contact.get with the checksum as `cs` URL param. If the - // contact resolves, the checksum is valid. - const res = await civi<{ id: number }>("Contact", "get", { - where: [["id", "=", Number(cid)]], - select: ["id"], - checksum: cs, - }); - return Array.isArray(res.values) && res.values.length === 1; + console.error("[civi] validateChecksum failed; treating as invalid:", e instanceof Error ? e.message : String(e)); + return false; } }