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;
|
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 {
|
function isCiviStubMode(): boolean {
|
||||||
return !(
|
return !(
|
||||||
process.env.CIVI_BASE_URL &&
|
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 });
|
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.mime_type ?? upstreamRes.headers.get("content-type"),
|
||||||
row.name,
|
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 safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": mime,
|
"Content-Type": mime,
|
||||||
"Content-Disposition": `${wantsDownload ? "attachment" : "inline"}; filename="${safeName}"`,
|
"Content-Disposition": `${disposition}; filename="${safeName}"`,
|
||||||
"Cache-Control": "private, no-store",
|
"Cache-Control": "private, no-store",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"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)) {
|
if (Number.isFinite(contentLength)) {
|
||||||
headers["Content-Length"] = String(contentLength);
|
headers["Content-Length"] = String(contentLength);
|
||||||
|
|||||||
Reference in New Issue
Block a user