Sicherheit: SVG-Upload für Logo/Favicon nicht mehr erlaubt
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.
This commit is contained in:
@@ -14,6 +14,22 @@ function isAssetKind(value: unknown): value is AssetKind {
|
|||||||
return value === "logo" || value === "favicon";
|
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) {
|
export async function POST(request: Request) {
|
||||||
if (!isSameOrigin(request)) {
|
if (!isSameOrigin(request)) {
|
||||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||||
@@ -26,7 +42,8 @@ export async function POST(request: Request) {
|
|||||||
if (!isAssetKind(kind) || !(file instanceof File)) {
|
if (!isAssetKind(kind) || !(file instanceof File)) {
|
||||||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!file.type.startsWith("image/")) {
|
const ext = ALLOWED_IMAGE_TYPES[file.type];
|
||||||
|
if (!ext) {
|
||||||
return NextResponse.json({ error: "invalid_image" }, { status: 400 });
|
return NextResponse.json({ error: "invalid_image" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +51,6 @@ export async function POST(request: Request) {
|
|||||||
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
|
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
|
|
||||||
const filename = `${kind}-${Date.now()}.${ext}`;
|
const filename = `${kind}-${Date.now()}.${ext}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await saveBrandingFile(filename, buffer);
|
await saveBrandingFile(filename, buffer);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { readBrandingFile } from "@/lib/branding";
|
import { readBrandingFile } from "@/lib/branding";
|
||||||
|
|
||||||
|
// Kein svg: würde mit Content-Type image/svg+xml ausgeliefert und ist
|
||||||
|
// bei direkter Navigation auf die Datei aktiv ausführbar (eingebettetes
|
||||||
|
// <script> läuft dann same-origin inkl. Session-Cookie) – anders als bei
|
||||||
|
// Einbettung per <img>/<link rel="icon">. Nur ungefährliche Rasterformate
|
||||||
|
// zulassen, siehe auch die Whitelist beim Upload in api/admin/branding.
|
||||||
const CONTENT_TYPES: Record<string, string> = {
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
jpeg: "image/jpeg",
|
jpeg: "image/jpeg",
|
||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
svg: "image/svg+xml",
|
|
||||||
ico: "image/x-icon",
|
ico: "image/x-icon",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function DesignEditor() {
|
|||||||
{dict.designEditor.logoUpload}
|
{dict.designEditor.logoUpload}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => uploadAsset("logo", e)}
|
onChange={(e) => uploadAsset("logo", e)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -201,7 +201,7 @@ export default function DesignEditor() {
|
|||||||
{dict.designEditor.faviconUpload}
|
{dict.designEditor.faviconUpload}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => uploadAsset("favicon", e)}
|
onChange={(e) => uploadAsset("favicon", e)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
Reference in New Issue
Block a user