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
This commit is contained in:
4 files changed
+31
-4
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '<svg><script>alert("xss")</script><rect width="1" height="1"/></svg>';
|
||||
const clean = sanitizeSvg(dirty);
|
||||
expect(clean).not.toBe(dirty);
|
||||
expect(clean).not.toContain("<script");
|
||||
expect(clean).toContain("<rect");
|
||||
});
|
||||
|
||||
it("looksLikeSvg detects SVG content regardless of MIME", () => {
|
||||
const svg = new TextEncoder().encode('<svg viewBox="0 0 24 24"><path d="M0 0"/></svg>');
|
||||
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 =
|
||||
'<svg viewBox="0 0 24 24"><path d="M12 2L2 22h20Z" fill="currentColor"/><circle cx="12" cy="8" r="2"/></svg>';
|
||||
|
||||
@@ -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 /<svg[\s>]/i.test(head);
|
||||
}
|
||||
|
||||
export { svgSanitizeOptions, purify };
|
||||
Reference in new issue
Block a user