Staff file proxy: allowlist inline mimes, force download otherwise

Defence in depth against XSS through a non-allowlisted upload path:
our /api/upload route validates mimes, but the underlying civicrm_file
row can be populated through other routes (Civi admin UI uploads,
imports). A row with mime_type=text/html or image/svg+xml would have
been served inline from this same-origin proxy.

- SAFE_INLINE_MIMES allowlist: png/jpeg/gif/webp/pdf only
- Anything outside it is rewritten to application/octet-stream plus
  Content-Disposition: attachment so the browser downloads
- Adds Content-Security-Policy sandbox so even a mistaken inline serve
  cannot run script or exfiltrate
This commit is contained in:
Joel Brock
2026-06-15 11:59:12 -07:00
parent 6850ff9dee
commit 49d0d24950
+35 -2
View File
@@ -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<string>([
"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<string, string> = {
"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);