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.
This commit is contained in:
Joel Brock
2026-10-30 11:17:52 -07:00
parent 818c979479
commit c9b610590e
2 changed files with 14 additions and 18 deletions
+3 -4
View File
@@ -245,9 +245,8 @@ async function probeStageOptionGroup(): Promise<CheckResult> {
async function probeChecksumApi(): Promise<CheckResult> { async function probeChecksumApi(): Promise<CheckResult> {
// Tries to call validateChecksum with a deliberately invalid checksum. // Tries to call validateChecksum with a deliberately invalid checksum.
// If the API exists, we get back {valid: false} or similar — that's // 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 // 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 // verifyChecksum fails closed and every form link will be rejected.
// also fail gracefully.
try { try {
await civi("Contact", "validateChecksum", { await civi("Contact", "validateChecksum", {
contactId: 1, contactId: 1,
@@ -266,7 +265,7 @@ async function probeChecksumApi(): Promise<CheckResult> {
return { return {
check: "contact_validate_checksum_api", check: "contact_validate_checksum_api",
ok: false, 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 { return {
+11 -14
View File
@@ -297,32 +297,29 @@ export async function civiMultipart<T = unknown>(
} }
/** /**
* 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 * Fails closed: any API error is treated as an invalid checksum. There is
* versions you may need to call Contact.get with the cs param and verify * deliberately no fallback — our API key is privileged, so any probe that
* the contact resolves. We use validateChecksum here and fall back to a * merely checks "does the contact resolve" would accept every checksum.
* Contact.get probe if it returns a "missing API" error. * /api/health confirms validateChecksum is available on the install.
*/ */
export async function verifyChecksum(cid: string, cs: string): Promise<boolean> { export async function verifyChecksum(cid: string, cs: string): Promise<boolean> {
if (isStubMode()) { if (isStubMode()) {
// STUB: any non-empty cs is "valid" so the UI can be exercised locally. // STUB: any non-empty cs is "valid" so the UI can be exercised locally.
return Boolean(cid && cs); return Boolean(cid && cs);
} }
const contactId = Number(cid);
if (!Number.isSafeInteger(contactId) || contactId <= 0) return false;
try { try {
const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", { const res = await civi<{ valid: boolean }>("Contact", "validateChecksum", {
contactId: Number(cid), contactId,
checksum: cs, checksum: cs,
}); });
return Boolean(res.values?.[0]?.valid); return Boolean(res.values?.[0]?.valid);
} catch (e) { } catch (e) {
// Fallback: try Contact.get with the checksum as `cs` URL param. If the console.error("[civi] validateChecksum failed; treating as invalid:", e instanceof Error ? e.message : String(e));
// contact resolves, the checksum is valid. return false;
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;
} }
} }