The Engagement Report tab wrote STAFF_REPORT_KEY into the iframe src for every user with "access CiviCRM", without checking contact permissions. Anyone could copy it from the DOM and read any org's report and files, bypassing Civi ACLs; the key never expired and landed in access logs. - Extension (0.4.0): Tab.php checks Contact_Permission::allow(VIEW) and mints sig = base64url(HMAC-SHA256(key, "webform-mw.staff-report.v1: <org>:<exp>")) with an 8h exp. The secret no longer leaves the server. - App: lib/staff-token.mjs verifies the token (constant-time, org must match, rejects expired or >12h tokens). /staff/report, /api/staff/report and /api/staff/file share staffAuthQuery(); client links carry exp/sig. - Legacy ?key= is still accepted (constant-time compare, logs a warning) so the app can deploy before the extension. Remove it once every Civi install runs extension 0.4.0. - PHP/Node signature compatibility is pinned by a PHP-generated vector.
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/**
|
|
* GET /api/staff/file?id=<fileId>&org=<orgId>&exp=<unix>&sig=<hmac>
|
|
*
|
|
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
|
|
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
|
* preview images and PDFs in-place. Civi's own `/civicrm/file` handler
|
|
* always sends `attachment`, which forces a download — that's correct for
|
|
* its UI but wrong for an embedded preview.
|
|
*
|
|
* Authorization layers:
|
|
* 1. Signed org-bound token (same gate as /api/staff/report; see
|
|
* lib/staff-auth.ts). The token's org must match `org`.
|
|
* 2. Per-file ownership probe: the requested fileId must appear as a
|
|
* value in one of the org's file-typed custom-field columns, OR in
|
|
* one of the org's activities' file-typed columns. This is the same
|
|
* ownership chain the staff report uses to surface the file in the
|
|
* first place. NOT enforced via civicrm_entity_file — that linkage
|
|
* is metadata-only here (upload anchors to submitter, not org).
|
|
*
|
|
* The upstream fetch uses the URL Civi returns from `Attachment.get`,
|
|
* which includes a freshly-minted `fcs` JWT. We don't carry any user
|
|
* session cookies — that JWT is the auth for `/civicrm/file`.
|
|
*
|
|
* STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't
|
|
* meaningful (there are no real bytes to serve).
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { staffAuthQuery } from "@/lib/staff-auth";
|
|
import { civi, civi3 } from "@/lib/civicrm";
|
|
import { resolveMime } from "@/lib/mime.mjs";
|
|
|
|
const MAX_BYTES = 10 * 1024 * 1024;
|
|
|
|
// Only mimes the lightbox actually renders inline are allowed through the
|
|
// `Content-Disposition: inline` path. Everything else gets coerced to
|
|
// application/octet-stream + attachment so it always downloads.
|
|
//
|
|
// Why this matters: our /api/upload route validates uploaded mimes
|
|
// against an allowlist, but the underlying civicrm_file row can be
|
|
// populated by other paths too — a Civi admin uploading directly through
|
|
// the CiviCRM UI, a future Civi import, etc. If any of those routes
|
|
// stored mime_type="text/html" or "image/svg+xml", an inline serve from
|
|
// this same-origin proxy would let arbitrary script run against
|
|
// /api/* and the staff key. Defence in depth: don't trust mime_type
|
|
// when the response carries `inline`.
|
|
//
|
|
// Explicitly NOT in this set: svg (script-bearing), html, xml, any text/*.
|
|
const SAFE_INLINE_MIMES = new Set<string>([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/gif",
|
|
"image/webp",
|
|
"application/pdf",
|
|
]);
|
|
|
|
function isCiviStubMode(): boolean {
|
|
return !(
|
|
process.env.CIVI_BASE_URL &&
|
|
process.env.CIVI_API_KEY &&
|
|
process.env.CIVI_SITE_KEY
|
|
);
|
|
}
|
|
|
|
interface AttachmentRow {
|
|
id: string | number;
|
|
url?: string;
|
|
mime_type?: string;
|
|
name?: string;
|
|
}
|
|
|
|
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
|
|
const res = await civi3<AttachmentRow>("Attachment", "get", {
|
|
id: fileId,
|
|
return: "id,url,mime_type,name",
|
|
sequential: 1,
|
|
});
|
|
return res.values?.[0] ?? null;
|
|
}
|
|
|
|
// Same groups /api/staff/report scans. Kept in sync deliberately: this
|
|
// proxy must only authorise files reachable through the same set of
|
|
// fields the report itself surfaces.
|
|
const ACTIVITY_GROUP_NAMES = [
|
|
"Check_in_data__organizing_",
|
|
"Stage_1",
|
|
"Stage_2",
|
|
"Stage_3",
|
|
"Stage_4",
|
|
"Stage_5",
|
|
];
|
|
const ORG_GROUP_NAMES = ["Food_Co_op_Organizing"];
|
|
const ACTIVITY_TYPE_NAME = "Check-in (organizing)";
|
|
|
|
interface FileFieldRefs {
|
|
org: string[];
|
|
activity: string[];
|
|
}
|
|
|
|
/**
|
|
* Discover the APIv4 field references for File-typed custom fields the
|
|
* staff report cares about. Returns refs like "Stage_1.Vision_Upload"
|
|
* split by whether they live on the Organization Contact or on Activities.
|
|
*/
|
|
async function discoverFileFieldRefs(): Promise<FileFieldRefs> {
|
|
const res = await civi<{
|
|
name: string;
|
|
data_type: string;
|
|
"custom_group_id.name": string;
|
|
}>("CustomField", "get", {
|
|
select: ["name", "data_type", "custom_group_id.name"],
|
|
where: [
|
|
["custom_group_id.name", "IN", [...ACTIVITY_GROUP_NAMES, ...ORG_GROUP_NAMES]],
|
|
["data_type", "=", "File"],
|
|
["is_active", "=", true],
|
|
],
|
|
limit: 500,
|
|
});
|
|
const org: string[] = [];
|
|
const activity: string[] = [];
|
|
for (const row of res.values ?? []) {
|
|
const group = row["custom_group_id.name"];
|
|
const ref = `${group}.${row.name}`;
|
|
if (ORG_GROUP_NAMES.includes(group)) org.push(ref);
|
|
else if (ACTIVITY_GROUP_NAMES.includes(group)) activity.push(ref);
|
|
}
|
|
return { org, activity };
|
|
}
|
|
|
|
/**
|
|
* Confirm fileId is reachable from this org through the same column
|
|
* ownership the staff report uses.
|
|
*
|
|
* Implementation: fetch the org row and the org's activities, selecting
|
|
* the file-typed custom-field columns, then check in JS whether any
|
|
* column value equals fileId. We avoid APIv4 OR clauses against custom
|
|
* fields because that combination has been fragile in practice; the
|
|
* SELECT-and-compare path is the same shape /api/staff/report uses
|
|
* successfully.
|
|
*
|
|
* We do NOT use civicrm_entity_file for this check: our upload route
|
|
* anchors files to the submitter's contact id (not the org's), so that
|
|
* linkage doesn't reflect ownership. The custom-field column is the
|
|
* authoritative chain.
|
|
*/
|
|
async function fileBelongsToOrg(fileId: number, orgId: number): Promise<boolean> {
|
|
let refs: FileFieldRefs;
|
|
try {
|
|
refs = await discoverFileFieldRefs();
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[staff/file] CustomField.get failed:", msg);
|
|
return false;
|
|
}
|
|
|
|
const matches = (rows: Array<Record<string, unknown>>, fieldRefs: string[]): boolean => {
|
|
for (const row of rows) {
|
|
for (const ref of fieldRefs) {
|
|
const v = row[ref];
|
|
if (v === undefined || v === null || v === "") continue;
|
|
if (Number(v) === fileId) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const orgProbe =
|
|
refs.org.length > 0
|
|
? civi<Record<string, unknown>>("Contact", "get", {
|
|
where: [["id", "=", orgId]],
|
|
select: ["id", ...refs.org],
|
|
limit: 1,
|
|
}).catch((e: unknown) => {
|
|
console.error(
|
|
"[staff/file] Contact.get probe failed:",
|
|
e instanceof Error ? e.message : String(e),
|
|
);
|
|
return { values: [] as Array<Record<string, unknown>> };
|
|
})
|
|
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
|
|
|
|
const activityProbe =
|
|
refs.activity.length > 0
|
|
? civi<Record<string, unknown>>("Activity", "get", {
|
|
where: [
|
|
["target_contact_id", "=", orgId],
|
|
["activity_type_id:name", "=", ACTIVITY_TYPE_NAME],
|
|
],
|
|
select: ["id", ...refs.activity],
|
|
limit: 500,
|
|
}).catch((e: unknown) => {
|
|
console.error(
|
|
"[staff/file] Activity.get probe failed:",
|
|
e instanceof Error ? e.message : String(e),
|
|
);
|
|
return { values: [] as Array<Record<string, unknown>> };
|
|
})
|
|
: Promise.resolve({ values: [] as Array<Record<string, unknown>> });
|
|
|
|
const [orgRes, actRes] = await Promise.all([orgProbe, activityProbe]);
|
|
return (
|
|
matches(orgRes.values ?? [], refs.org) || matches(actRes.values ?? [], refs.activity)
|
|
);
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const url = new URL(req.url);
|
|
const idStr = url.searchParams.get("id");
|
|
const orgStr = url.searchParams.get("org");
|
|
const wantsDownload = url.searchParams.get("dl") === "1";
|
|
|
|
if (
|
|
!staffAuthQuery({
|
|
org: orgStr,
|
|
exp: url.searchParams.get("exp"),
|
|
sig: url.searchParams.get("sig"),
|
|
key: url.searchParams.get("key"),
|
|
})
|
|
) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
const fileId = Number(idStr);
|
|
const orgId = Number(orgStr);
|
|
if (!idStr || !Number.isFinite(fileId) || fileId <= 0) {
|
|
return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 });
|
|
}
|
|
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
|
|
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
|
|
}
|
|
|
|
if (isCiviStubMode()) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
let row: AttachmentRow | null;
|
|
try {
|
|
row = await fetchAttachment(fileId);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[staff/file] Attachment.get failed:", msg);
|
|
return NextResponse.json({ error: "Could not look up the file." }, { status: 502 });
|
|
}
|
|
if (!row || !row.url) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
// Authorisation: prove this file actually belongs to `orgId` via the
|
|
// custom-field columns the staff report itself surfaces. Without this a
|
|
// staff key (which is org-agnostic) could be used to enumerate file ids
|
|
// outside any report context.
|
|
const owned = await fileBelongsToOrg(fileId, orgId);
|
|
if (!owned) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
// The signed URL Civi returns is sometimes a relative path (depends on
|
|
// Civi config). Normalise against CIVI_BASE_URL so fetch() has an
|
|
// absolute URL.
|
|
const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, "");
|
|
const upstream = row.url.startsWith("http")
|
|
? row.url
|
|
: `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`;
|
|
|
|
let upstreamRes: Response;
|
|
try {
|
|
upstreamRes = await fetch(upstream, { cache: "no-store" });
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[staff/file] upstream fetch failed:", msg);
|
|
return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 });
|
|
}
|
|
if (!upstreamRes.ok || !upstreamRes.body) {
|
|
return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 });
|
|
}
|
|
|
|
const contentLengthRaw = upstreamRes.headers.get("content-length");
|
|
const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN;
|
|
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
|
|
return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 });
|
|
}
|
|
|
|
const resolved = resolveMime(
|
|
row.mime_type ?? upstreamRes.headers.get("content-type"),
|
|
row.name,
|
|
);
|
|
// Inline is only allowed for mimes the lightbox actually renders. Anything
|
|
// else (incl. an attacker-controlled mime_type from a non-allowlisted
|
|
// upload path) is downgraded to octet-stream + attachment so the browser
|
|
// downloads instead of executing.
|
|
const inlineSafe = !wantsDownload && SAFE_INLINE_MIMES.has(resolved);
|
|
const mime = inlineSafe ? resolved : "application/octet-stream";
|
|
const disposition = inlineSafe ? "inline" : "attachment";
|
|
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": mime,
|
|
"Content-Disposition": `${disposition}; filename="${safeName}"`,
|
|
"Cache-Control": "private, no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
};
|
|
// For attachment responses, layer on a strict sandbox CSP as defence in
|
|
// depth — the file is being downloaded so the CSP has no UX effect, but
|
|
// if a future change ever flips it to inline by mistake, the sandbox
|
|
// blocks script + plugins. For inline responses we rely on the SAFE
|
|
// MIME allowlist + nosniff + the app's global CSP, because a strict
|
|
// `sandbox` here breaks Chrome's PDF viewer (it can't load fonts or
|
|
// plugin-mode rendering under sandbox).
|
|
if (!inlineSafe) {
|
|
headers["Content-Security-Policy"] = "sandbox; default-src 'none'";
|
|
}
|
|
if (Number.isFinite(contentLength)) {
|
|
headers["Content-Length"] = String(contentLength);
|
|
}
|
|
|
|
return new NextResponse(upstreamRes.body, { status: 200, headers });
|
|
}
|