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 });
|
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> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
|
||||||
const creds = Buffer.from(
|
const creds = Buffer.from(
|
||||||
@@ -75,7 +94,7 @@ export async function GET(req: NextRequest) {
|
|||||||
headers["Authorization"] = `Basic ${creds}`;
|
headers["Authorization"] = `Basic ${creds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(row.url, { headers });
|
const upstream = await fetch(upstreamUrl, { headers, redirect: "manual" });
|
||||||
if (!upstream.ok || !upstream.body) {
|
if (!upstream.ok || !upstream.body) {
|
||||||
console.error(
|
console.error(
|
||||||
`[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`,
|
`[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 });
|
return new NextResponse("Upstream error", { status: 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeName = (row.name || `file-${id}`).replace(/"/g, "");
|
// XSS guard: only allow a fixed allowlist of MIME types to render inline
|
||||||
const mime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream";
|
// (browsers execute scripts inside SVGs and HTML, and will sniff some
|
||||||
const isInline = mime.startsWith("image/") || mime === "application/pdf";
|
// 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, {
|
return new NextResponse(upstream.body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": mime,
|
"content-type": servedMime,
|
||||||
"content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`,
|
"content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`,
|
||||||
"cache-control": "private, max-age=60",
|
"cache-control": "private, max-age=60",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"content-security-policy": "default-src 'none'; sandbox; style-src 'unsafe-inline'",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -247,7 +247,9 @@ function FilePreviewItem({
|
|||||||
const name = v.file_name ?? `file-${id}`;
|
const name = v.file_name ?? `file-${id}`;
|
||||||
const href = `/api/staff/file?id=${encodeURIComponent(id)}&key=${encodeURIComponent(authKey)}`;
|
const href = `/api/staff/file?id=${encodeURIComponent(id)}&key=${encodeURIComponent(authKey)}`;
|
||||||
const ext = (name.split(".").pop() ?? "").toLowerCase();
|
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";
|
const isPdf = ext === "pdf";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user