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:
@@ -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) {
|
||||
|
||||
@@ -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 <img> here would just show a broken thumbnail.
|
||||
const isImage = ["png", "jpg", "jpeg", "gif", "webp"].includes(ext);
|
||||
const isPdf = ext === "pdf";
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user