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 };