diff --git a/app/api/staff/file/route.ts b/app/api/staff/file/route.ts index 6864a52..b2971ca 100644 --- a/app/api/staff/file/route.ts +++ b/app/api/staff/file/route.ts @@ -31,6 +31,28 @@ 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([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "application/pdf", +]); + function isCiviStubMode(): boolean { return !( process.env.CIVI_BASE_URL && @@ -154,17 +176,28 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 }); } - const mime = resolveMime( + 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 = { "Content-Type": mime, - "Content-Disposition": `${wantsDownload ? "attachment" : "inline"}; filename="${safeName}"`, + "Content-Disposition": `${disposition}; filename="${safeName}"`, "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff", + // Belt-and-suspenders: even if a future change accidentally lets a + // scriptable mime through the allowlist, the sandbox CSP keeps the + // response from running script or talking to anything else. + "Content-Security-Policy": "sandbox; default-src 'none'; img-src 'self'; object-src 'self'", }; if (Number.isFinite(contentLength)) { headers["Content-Length"] = String(contentLength);