- Allowlist inline MIME types (png/jpeg/gif/webp/pdf only); everything else, including SVG and HTML, served as application/octet-stream with content-disposition: attachment. - X-Content-Type-Options: nosniff and a restrictive CSP on every response. - Validate the upstream URL Civi returns: must match CIVI_BASE_URL origin before we attach basic-auth creds and follow it. redirect: manual to prevent off-host hops. - Drop SVG from the client's inline-image list (server forces download).
136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
/**
|
|
* GET /api/staff/file?id=<civi_file_id>&key=<secret>
|
|
*
|
|
* Streams an attachment from CiviCRM to the caller. The Civi API user's
|
|
* credentials never leave the server. Auth is the same shared
|
|
* STAFF_REPORT_KEY used by /api/staff/report.
|
|
*
|
|
* In stub mode, returns a tiny placeholder PNG so the UI's preview path
|
|
* is exercisable in dev.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { isStaffKeyValid } from "@/lib/staff-auth";
|
|
import { civi } from "@/lib/civicrm";
|
|
|
|
function isCiviStubMode(): boolean {
|
|
return !(
|
|
process.env.CIVI_BASE_URL &&
|
|
process.env.CIVI_API_KEY &&
|
|
process.env.CIVI_SITE_KEY
|
|
);
|
|
}
|
|
|
|
// 1x1 transparent PNG, base64-encoded — used as a stub attachment so the
|
|
// UI's image preview path renders something in dev.
|
|
const STUB_PNG_B64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const url = new URL(req.url);
|
|
const key = url.searchParams.get("key");
|
|
const idStr = url.searchParams.get("id");
|
|
|
|
if (!isStaffKeyValid(key)) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
const id = Number(idStr);
|
|
if (!idStr || !Number.isFinite(id) || id <= 0) {
|
|
return new NextResponse("Bad request", { status: 400 });
|
|
}
|
|
|
|
if (isCiviStubMode()) {
|
|
const bytes = Buffer.from(STUB_PNG_B64, "base64");
|
|
return new NextResponse(bytes, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "image/png",
|
|
"content-disposition": `inline; filename="stub-${id}.png"`,
|
|
"cache-control": "private, max-age=60",
|
|
},
|
|
});
|
|
}
|
|
|
|
try {
|
|
// Look up the attachment URL and metadata.
|
|
const meta = await civi<{ id: number; url: string; mime_type: string; name: string }>(
|
|
"Attachment",
|
|
"get",
|
|
{
|
|
select: ["id", "url", "mime_type", "name"],
|
|
where: [["id", "=", id]],
|
|
},
|
|
);
|
|
const row = meta.values?.[0];
|
|
if (!row?.url) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
// SSRF guard: only follow URLs whose origin matches CIVI_BASE_URL. Civi
|
|
// returns absolute URLs for attachments; if a compromised Civi (or DB row
|
|
// tamper) ever set this to an attacker-controlled host, the basic-auth
|
|
// creds attached below would leak. Validating the origin closes that.
|
|
const civiOrigin = new URL(process.env.CIVI_BASE_URL!).origin;
|
|
let upstreamUrl: URL;
|
|
try {
|
|
upstreamUrl = new URL(row.url);
|
|
} catch {
|
|
console.error(`[staff/file] malformed civi url id=${id}`);
|
|
return new NextResponse("Upstream error", { status: 502 });
|
|
}
|
|
if (upstreamUrl.origin !== civiOrigin) {
|
|
console.error(
|
|
`[staff/file] refused cross-origin upstream id=${id} origin=${upstreamUrl.origin}`,
|
|
);
|
|
return new NextResponse("Upstream error", { status: 502 });
|
|
}
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
|
const creds = Buffer.from(
|
|
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
|
|
).toString("base64");
|
|
headers["Authorization"] = `Basic ${creds}`;
|
|
}
|
|
|
|
const upstream = await fetch(upstreamUrl, { headers, redirect: "manual" });
|
|
if (!upstream.ok || !upstream.body) {
|
|
console.error(
|
|
`[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`,
|
|
);
|
|
return new NextResponse("Upstream error", { status: 502 });
|
|
}
|
|
|
|
// XSS guard: only allow a fixed allowlist of MIME types to render inline
|
|
// (browsers execute scripts inside SVGs and HTML, and will sniff some
|
|
// ambiguous types). Everything else is forced to attachment with a
|
|
// neutralised content-type. nosniff blocks MIME sniffing entirely.
|
|
const SAFE_INLINE = new Set([
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/gif",
|
|
"image/webp",
|
|
"application/pdf",
|
|
]);
|
|
const safeName = (row.name || `file-${id}`).replace(/[\r\n"]/g, "");
|
|
const declaredMime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream";
|
|
const isInline = SAFE_INLINE.has(declaredMime);
|
|
const servedMime = isInline ? declaredMime : "application/octet-stream";
|
|
return new NextResponse(upstream.body, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": servedMime,
|
|
"content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`,
|
|
"cache-control": "private, max-age=60",
|
|
"x-content-type-options": "nosniff",
|
|
"content-security-policy": "default-src 'none'; sandbox; style-src 'unsafe-inline'",
|
|
},
|
|
});
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error(`[staff/file] fetch threw: ${msg}`);
|
|
return new NextResponse("Upstream error", { status: 502 });
|
|
}
|
|
}
|