From e836d07d4c9c4f475e3bbe2c408fb8523c6b4f7e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 7 Aug 2026 21:22:25 +0200 Subject: [PATCH] fix: content-based SVG detection, guard tests, document REDIS_CACHE_DB - Detect SVG by content (magic bytes) in addition to MIME type to prevent sanitization bypass via mislabeled uploads - Add guard test verifying DOMPurify actually strips dirty payloads - Add looksLikeSvg detection test - Document REDIS_CACHE_DB in .env.example --- .env.example | 4 ++++ .../src/app/api/user-medias/[id]/route.ts | 7 ++++--- .../src/app/api/user-medias/sanitize.spec.ts | 17 ++++++++++++++++- .../src/app/api/user-medias/svg-purify.ts | 7 +++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 22dca3adb..87e1b5a5a 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,10 @@ TURBO_TELEMETRY_DISABLED=1 # Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock # DOCKER_SOCKET_PATHS= +# Redis DB index for the Next.js cache handler (default: 1). +# Separate from the main pub/sub DB (0) to allow independent FLUSHDB. +# REDIS_CACHE_DB=1 + # Enable kubernetes tool # ENABLE_KUBERNETES=true diff --git a/apps/nextjs/src/app/api/user-medias/[id]/route.ts b/apps/nextjs/src/app/api/user-medias/[id]/route.ts index 35cd3ea61..ce2aecdb1 100644 --- a/apps/nextjs/src/app/api/user-medias/[id]/route.ts +++ b/apps/nextjs/src/app/api/user-medias/[id]/route.ts @@ -5,7 +5,7 @@ import type { NextRequest } from "next/server"; import { db, eq } from "@homarr/db"; import { medias } from "@homarr/db/schema"; -import { sanitizeSvg } from "../svg-purify"; +import { looksLikeSvg, sanitizeSvg } from "../svg-purify"; export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) { const params = await props.params; @@ -23,8 +23,9 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ id: stri let content = new Uint8Array(image.content); - // Sanitize SVG content to prevent XSS attacks - if (image.contentType === "image/svg+xml" || image.contentType === "image/svg") { + const isSvgMime = image.contentType === "image/svg+xml" || image.contentType === "image/svg"; + const isSvgContent = !isSvgMime && looksLikeSvg(content); + if (isSvgMime || isSvgContent) { const svgText = new TextDecoder().decode(content); const sanitized = sanitizeSvg(svgText); content = new TextEncoder().encode(sanitized); diff --git a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts index c30e24401..f3adde9c2 100644 --- a/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts +++ b/apps/nextjs/src/app/api/user-medias/sanitize.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { purify, sanitizeSvg, svgSanitizeOptions } from "./svg-purify"; +import { looksLikeSvg, purify, sanitizeSvg, svgSanitizeOptions } from "./svg-purify"; describe("SVG sanitization", () => { it("strips script tags from SVG", () => { @@ -67,6 +67,21 @@ describe("SVG sanitization", () => { expect(clean).not.toContain("onbegin"); }); + it("DOMPurify actually strips dirty payload (guard against no-op shim)", () => { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toBe(dirty); + expect(clean).not.toContain(" { + const svg = new TextEncoder().encode(''); + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + expect(looksLikeSvg(svg)).toBe(true); + expect(looksLikeSvg(png)).toBe(false); + }); + it("preserves well-formed SVG with self-closing children", () => { const icon = ''; diff --git a/apps/nextjs/src/app/api/user-medias/svg-purify.ts b/apps/nextjs/src/app/api/user-medias/svg-purify.ts index 4f196bc4f..3e2699d75 100644 --- a/apps/nextjs/src/app/api/user-medias/svg-purify.ts +++ b/apps/nextjs/src/app/api/user-medias/svg-purify.ts @@ -91,4 +91,11 @@ export const sanitizeSvg = (svgText: string) => { return svgMatch ? svgMatch[0] : ""; }; +// ponytail: content-based SVG detection — catches mislabeled MIME types. +// Ceiling: only checks first 512 bytes; deeply embedded SVG in other XML won't match. +export function looksLikeSvg(content: Uint8Array): boolean { + const head = new TextDecoder().decode(content.subarray(0, 512)); + return /]/i.test(head); +} + export { svgSanitizeOptions, purify };