Staff file proxy: harden against SVG XSS and SSRF

- 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).
This commit is contained in:
Joel Brock
2026-06-05 17:02:42 -07:00
parent 64076a145b
commit d7a1396640
2 changed files with 41 additions and 6 deletions
+38 -5
View File
@@ -67,6 +67,25 @@ export async function GET(req: NextRequest) {
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(
@@ -75,7 +94,7 @@ export async function GET(req: NextRequest) {
headers["Authorization"] = `Basic ${creds}`;
}
const upstream = await fetch(row.url, { headers });
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}`,
@@ -83,15 +102,29 @@ export async function GET(req: NextRequest) {
return new NextResponse("Upstream error", { status: 502 });
}
const safeName = (row.name || `file-${id}`).replace(/"/g, "");
const mime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream";
const isInline = mime.startsWith("image/") || mime === "application/pdf";
// 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": mime,
"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) {