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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user