diff --git a/app/api/staff/file/route.ts b/app/api/staff/file/route.ts index f055e5d..7c45a03 100644 --- a/app/api/staff/file/route.ts +++ b/app/api/staff/file/route.ts @@ -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 = {}; 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) { diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index e87efd4..8443853 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -247,7 +247,9 @@ function FilePreviewItem({ const name = v.file_name ?? `file-${id}`; const href = `/api/staff/file?id=${encodeURIComponent(id)}&key=${encodeURIComponent(authKey)}`; const ext = (name.split(".").pop() ?? "").toLowerCase(); - const isImage = ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext); + // SVG omitted on purpose — the proxy forces SVG to download (XSS hardening), + // so an inline here would just show a broken thumbnail. + const isImage = ["png", "jpg", "jpeg", "gif", "webp"].includes(ext); const isPdf = ext === "pdf"; return (