Files
WebForm-mw/app/api/staff/file/route.ts
T
Joel Brock e74462da0b Lightbox: fix proxy 404 and PDF iframe block
Two bugs surfaced on first dev-server test:

1. /api/staff/file 404s for valid file ids. The old per-org check
   read civicrm_entity_file and required entity_id==orgId, but our
   upload route anchors files to the submitter's contact id, not the
   org's — the entity_file row is metadata-only on this install
   (see comment in app/api/upload/route.ts). The custom-field column
   is the real ownership signal, which /api/staff/report already uses,
   and the staff key already gates org access. Drop the bogus check;
   keep the entity_table whitelist as defence.

2. Same-origin PDF iframe blocked by frame-ancestors 'none'. The
   strict global CSP excludes /staff/report; add /api/staff/file to
   the same embed-friendly profile so the lightbox iframe can load.

Also move the sandbox/default-src 'none' CSP to the attachment path
only — a strict sandbox header breaks Chrome's PDF viewer on inline
responses (it needs to load fonts and plugin-mode rendering). On
inline we rely on the SAFE_INLINE_MIMES allowlist + X-Content-Type-
Options + the app's global CSP.
2026-06-15 16:00:30 -07:00

204 lines
7.9 KiB
TypeScript

/**
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
*
* 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. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
* 2. Server-side check that the requested file is actually linked to the
* `org`. This prevents the staff key from being used to pull arbitrary
* file ids out of CiviCRM — a file is reachable only if its
* entity_table/entity_id ties back to the org (directly, for org
* custom-field files; or via an activity's target_contact_id, for
* activity custom-field files).
*
* 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 { isStaffKeyValid } from "@/lib/staff-auth";
import { 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;
entity_table?: string;
}
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
const res = await civi3<AttachmentRow>("Attachment", "get", {
id: fileId,
return: "id,url,mime_type,name,entity_table",
sequential: 1,
});
return res.values?.[0] ?? null;
}
const ALLOWED_ENTITY_TABLES = new Set(["civicrm_contact", "civicrm_activity"]);
/**
* Confirm this file id is one our staff report would reasonably surface.
*
* We deliberately do NOT tie the file to `orgId` via `civicrm_entity_file`.
* That linkage is metadata-only on this install: our upload route anchors
* uploads to the submitter's contact id (not the org's), because the
* semantic ownership lives in the custom-field column on the activity or
* org, not in `civicrm_entity_file`. The rest of `/api/staff/*` already
* trusts the staff key as the org-agnostic gate, so requiring strict
* file→org binding here would be stricter (and broken: false-404s) than
* the rest of the surface.
*
* The entity_table whitelist still serves as defence: anything that didn't
* come from the Civi paths we know about (contact files, activity files)
* never gets proxied.
*/
function isProxyableEntityTable(row: AttachmentRow): boolean {
return ALLOWED_ENTITY_TABLES.has(String(row.entity_table ?? ""));
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const key = url.searchParams.get("key");
const idStr = url.searchParams.get("id");
const orgStr = url.searchParams.get("org");
const wantsDownload = url.searchParams.get("dl") === "1";
if (!isStaffKeyValid(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 });
}
if (!isProxyableEntityTable(row)) {
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 });
}