From df471e0e88268c13c4b043c2ff2d506e8f72b080 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 30 Oct 2026 11:19:07 -0700 Subject: [PATCH] Verify client-supplied file ids on submit /api/submit wrote whatever {id} the client sent into file custom fields. A link holder could point their org at any civicrm_file id, learn its filename via /api/report, and make the staff file proxy treat it as theirs. Each file id must now either be anchored to the submitting contact (how /api/upload stores uploads) or equal the org's current value for that field; otherwise the submit is rejected with a 400. --- app/api/submit/route.ts | 76 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts index b995f0f..ea274be 100644 --- a/app/api/submit/route.ts +++ b/app/api/submit/route.ts @@ -14,7 +14,8 @@ */ import { NextResponse } from "next/server"; -import { civi, verifyChecksum } from "@/lib/civicrm"; +import { civi, civi3, verifyChecksum } from "@/lib/civicrm"; +import { loadPrefill } from "@/lib/prefill"; import { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import type { SubmitPayload } from "@/types/form"; import { appEnv, redact } from "@/lib/env"; @@ -29,6 +30,65 @@ function isStubMode(): boolean { } const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f])); +const FILE_FIELDS = allFields.filter((f) => f.type === "file"); + +/** + * File fields arrive as {id, file_name} (or a bare id). Normalize to a + * positive integer civicrm_file id, or null when cleared/malformed. + */ +function fileIdOf(value: unknown): number | null { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as { id?: unknown }).id + : value; + if (typeof raw !== "number" && typeof raw !== "string") return null; + const n = Number(raw); + return Number.isSafeInteger(n) && n > 0 ? n : null; +} + +/** The org's current file-field values, keyed by form field name. */ +async function currentFileValues(orgId: number): Promise> { + const { values } = await loadPrefill(orgId, FILE_FIELDS, ACTIVITY_TYPE_NAME); + const contactRefs = FILE_FIELDS.map((f) => f.civiContactField).filter((s): s is string => Boolean(s)); + if (contactRefs.length > 0) { + const res = await civi>("Contact", "get", { + select: ["id", ...contactRefs], + where: [["id", "=", orgId]], + }); + const org = res.values?.[0] ?? {}; + for (const f of FILE_FIELDS) { + if (f.civiContactField && org[f.civiContactField] != null) values[f.name] = org[f.civiContactField]; + } + } + return values; +} + +/** + * File ids come from the client, so prove each one belongs to this + * submitter: either uploaded by this contact via /api/upload (which + * anchors the file to civicrm_contact/) or unchanged from the org's + * current value. Otherwise a link holder could attach — and then read the + * filename of — any file in Civi, and the staff file proxy would treat it + * as belonging to their org. + */ +async function fileRefsAllowed(cid: number, orgId: number, values: Record): Promise { + let current: Record | undefined; + for (const field of FILE_FIELDS) { + if (!(field.name in values)) continue; + const id = fileIdOf(values[field.name]); + if (id === null) continue; // cleared + const att = await civi3<{ entity_table?: string; entity_id?: string | number }>("Attachment", "get", { + id, + sequential: 1, + }).catch(() => ({ values: [] })); + const row = att.values?.[0]; + if (row?.entity_table === "civicrm_contact" && Number(row.entity_id) === cid) continue; + current ??= await currentFileValues(orgId); + if (fileIdOf(current[field.name]) === id) continue; + return false; + } + return true; +} export async function POST(req: Request) { // Rate limit per client IP. Generous default — 10 submissions per minute. @@ -103,6 +163,13 @@ async function runSubmit(cid: string, cs: string, values: Record