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.
This commit is contained in:
+70
-6
@@ -14,7 +14,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
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 { allFields, ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
||||||
import type { SubmitPayload } from "@/types/form";
|
import type { SubmitPayload } from "@/types/form";
|
||||||
import { appEnv, redact } from "@/lib/env";
|
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 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<Record<string, unknown>> {
|
||||||
|
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<Record<string, unknown>>("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/<cid>) 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<string, unknown>): Promise<boolean> {
|
||||||
|
let current: Record<string, unknown> | 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) {
|
export async function POST(req: Request) {
|
||||||
// Rate limit per client IP. Generous default — 10 submissions per minute.
|
// Rate limit per client IP. Generous default — 10 submissions per minute.
|
||||||
@@ -103,6 +163,13 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
|
|||||||
}
|
}
|
||||||
const orgId = orgs[0].contact_id_b;
|
const orgId = orgs[0].contact_id_b;
|
||||||
|
|
||||||
|
if (!(await fileRefsAllowed(Number(cid), orgId, values ?? {}))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "One of the attached files couldn't be verified. Please re-upload it and try again." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Split incoming values into:
|
// Split incoming values into:
|
||||||
// - activityRecord: fields with civiField → written to the new activity
|
// - activityRecord: fields with civiField → written to the new activity
|
||||||
// - orgContactValues: fields with civiContactField → written to the org
|
// - orgContactValues: fields with civiContactField → written to the org
|
||||||
@@ -126,11 +193,8 @@ async function runSubmit(cid: string, cs: string, values: Record<string, unknown
|
|||||||
// that's what Civi stores in the custom column. If the user left a
|
// that's what Civi stores in the custom column. If the user left a
|
||||||
// prior attachment alone, we receive the same prefill shape and
|
// prior attachment alone, we receive the same prefill shape and
|
||||||
// still write the same id (no-op effectively).
|
// still write the same id (no-op effectively).
|
||||||
let civiValue: unknown = value;
|
// fileRefsAllowed() above has already vetted these ids.
|
||||||
if (field.type === "file" && value && typeof value === "object" && !Array.isArray(value)) {
|
const civiValue: unknown = field.type === "file" ? fileIdOf(value) : value;
|
||||||
const v = value as { id?: unknown };
|
|
||||||
civiValue = typeof v.id === "number" || typeof v.id === "string" ? v.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.civiContactField) {
|
if (field.civiContactField) {
|
||||||
orgContactValues[field.civiContactField] = civiValue;
|
orgContactValues[field.civiContactField] = civiValue;
|
||||||
|
|||||||
Reference in New Issue
Block a user