Der Branding-Upload akzeptierte bisher jeden Dateityp, der clientseitig mit "image/" beginnt, inkl. SVG. Anders als beim vorherigen Supabase- Storage-Bucket (andere Origin) wird die Datei jetzt auf der eigenen Origin unter /branding/[filename] ausgeliefert – eine SVG mit eingebettetem <script> hätte bei direktem Aufruf der Datei-URL (z. B. "Bild in neuem Tab öffnen") mit dem Session-Cookie eines anderen Admins ausgeführt werden können und wäre dabei auch am CSRF-Schutz vorbeigekommen (Origin stimmt ja). Upload-Route erlaubt jetzt nur noch eine Whitelist an Rasterformaten (png/jpg/webp/gif/ico), die Dateiendung wird aus dem geprüften MIME-Typ abgeleitet statt aus dem vom Client gesendeten Dateinamen. Die Ausliefer-Route kennt "svg" als Content-Type nicht mehr (Defense in Depth). Gefunden bei der Sicherheitsprüfung vor dem Merge nach master.
93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { queryOne, query } from "@/lib/db";
|
||
import { removeBrandingFile, saveBrandingFile } from "@/lib/branding";
|
||
import { authErrorResponse, requireRole } from "@/lib/auth/session";
|
||
import { isSameOrigin } from "@/lib/origin";
|
||
|
||
type AssetKind = "logo" | "favicon";
|
||
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
|
||
logo: "logo_path",
|
||
favicon: "favicon_path",
|
||
};
|
||
|
||
function isAssetKind(value: unknown): value is AssetKind {
|
||
return value === "logo" || value === "favicon";
|
||
}
|
||
|
||
// Whitelist statt "image/*": svg absichtlich ausgeschlossen. Eine SVG
|
||
// mit eingebettetem <script> würde als image/svg+xml auf der eigenen
|
||
// Origin ausgeliefert und bei direktem Aufruf der Datei-URL ausgeführt
|
||
// (inkl. Session-Cookie) – anders als Rasterformate. Die Dateiendung
|
||
// wird aus dem geprüften MIME-Typ abgeleitet statt aus dem vom Client
|
||
// gesendeten Dateinamen, damit der gespeicherte Dateiname nicht vom
|
||
// Client bestimmt wird.
|
||
const ALLOWED_IMAGE_TYPES: Record<string, string> = {
|
||
"image/png": "png",
|
||
"image/jpeg": "jpg",
|
||
"image/webp": "webp",
|
||
"image/gif": "gif",
|
||
"image/x-icon": "ico",
|
||
"image/vnd.microsoft.icon": "ico",
|
||
};
|
||
|
||
export async function POST(request: Request) {
|
||
if (!isSameOrigin(request)) {
|
||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||
}
|
||
try {
|
||
await requireRole(["admin"]);
|
||
const form = await request.formData();
|
||
const kind = form.get("kind");
|
||
const file = form.get("file");
|
||
if (!isAssetKind(kind) || !(file instanceof File)) {
|
||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||
}
|
||
const ext = ALLOWED_IMAGE_TYPES[file.type];
|
||
if (!ext) {
|
||
return NextResponse.json({ error: "invalid_image" }, { status: 400 });
|
||
}
|
||
|
||
const old = await queryOne<{ path: string | null }>(
|
||
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
|
||
);
|
||
|
||
const filename = `${kind}-${Date.now()}.${ext}`;
|
||
const buffer = Buffer.from(await file.arrayBuffer());
|
||
await saveBrandingFile(filename, buffer);
|
||
|
||
await query(`update settings set ${ASSET_COLUMN[kind]} = $1 where id = 1`, [filename]);
|
||
if (old?.path) await removeBrandingFile(old.path);
|
||
|
||
return NextResponse.json({ path: filename });
|
||
} catch (err) {
|
||
const res = authErrorResponse(err);
|
||
if (res) return res;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
export async function DELETE(request: Request) {
|
||
if (!isSameOrigin(request)) {
|
||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||
}
|
||
try {
|
||
await requireRole(["admin"]);
|
||
const { kind } = (await request.json()) as { kind?: unknown };
|
||
if (!isAssetKind(kind)) {
|
||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||
}
|
||
|
||
const old = await queryOne<{ path: string | null }>(
|
||
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
|
||
);
|
||
await query(`update settings set ${ASSET_COLUMN[kind]} = null where id = 1`);
|
||
if (old?.path) await removeBrandingFile(old.path);
|
||
|
||
return NextResponse.json({ ok: true });
|
||
} catch (err) {
|
||
const res = authErrorResponse(err);
|
||
if (res) return res;
|
||
throw err;
|
||
}
|
||
}
|