/** * Comprehensive security tests for the SVG sanitizer. * * Covers all known SVG-based attack vectors: XSS, XXE, SSRF, URI scheme * obfuscation, animation injection, and filter-based SSRF. */ import { describe, expect, it } from "vitest"; import { sanitizeSvg } from "../../../apps/api/src/lib/svg-sanitize.js"; /** Wrap a payload fragment inside a minimal valid SVG. */ function wrapSvg(inner: string, attrs = ""): string { return `${inner}`; } /** Run sanitizeSvg on a string and return the result as a string. */ function sanitize(svg: string): string { return sanitizeSvg(Buffer.from(svg, "utf-8")).toString("utf-8"); } // ── XSS: Script Injection ──────────────────────────────────────────────────── describe("SVG sanitizer -- XSS script injection", () => { it("strips standard "); const result = sanitize(svg); expect(result).not.toContain(" tags", () => { const svg = wrapSvg(""); const result = sanitize(svg); expect(result).not.toMatch(/"); const result = sanitize(svg); expect(result).not.toContain(" { it("removes onload event handler", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).not.toMatch(/\bonload\s*=/i); expect(result).toContain('data-removed=""'); }); }); // ── XSS: CDATA Bypass ─────────────────────────────────────────────────────── describe("SVG sanitizer -- CDATA bypass", () => { it("strips CDATA sections that hide script content", () => { const svg = wrapSvg(""); const result = sanitize(svg); expect(result).not.toContain("CDATA"); expect(result).not.toContain(" { it("removes DOCTYPE with file-read XXE entity", () => { const svg = ']>' + '&xxe;'; const result = sanitize(svg); expect(result).not.toMatch(/ { const svg = ']>' + '&xxe;'; const result = sanitize(svg); expect(result).not.toMatch(/ { // This tests the scenario where an entity defines an obfuscated javascript: URI. // The DOCTYPE (and its entity definitions) are stripped entirely, making &x; unresolvable. const svg = ']>' + 'click'; const result = sanitize(svg); expect(result).not.toMatch(/ { it("strips foreignObject with embedded HTML body", () => { const svg = wrapSvg( '' + '' + "", ); const result = sanitize(svg); expect(result).not.toContain(""); expect(result).not.toContain(" variant", () => { const svg = wrapSvg("malicious"); const result = sanitize(svg); expect(result).not.toMatch(/ { it("blocks data: URI in href", () => { const svg = wrapSvg( 'click', ); const result = sanitize(svg); expect(result).not.toMatch(/href\s*=\s*["']data:text\/html/i); }); it("blocks entity-encoded javascript: URI in href", () => { const svg = wrapSvg( '' + "click", ); const result = sanitize(svg); expect(result).not.toContain("javascript:"); expect(result).not.toMatch(/href\s*=\s*["']javascript:/i); }); it("blocks newline-obfuscated javascript: URI in href", () => { const svg = wrapSvg('click'); const result = sanitize(svg); expect(result).not.toContain("javascript:"); expect(result).not.toMatch(/href\s*=\s*["']javascript:/i); }); it("blocks null-byte-obfuscated javascript: URI in href", () => { const svg = wrapSvg('click'); const result = sanitize(svg); expect(result).not.toContain("javascript:"); expect(result).not.toMatch(/href\s*=\s*["']javascript:/i); }); it("blocks tab-obfuscated javascript: URI in href", () => { const svg = wrapSvg('click'); const result = sanitize(svg); expect(result).not.toContain("javascript:"); expect(result).not.toMatch(/href\s*=\s*["']javascript:/i); }); it("blocks embedded image with data:text/html href", () => { const svg = wrapSvg( '', ); const result = sanitize(svg); expect(result).not.toMatch(/href\s*=\s*["']data:text\/html/i); }); }); // ── XInclude ───────────────────────────────────────────────────────────────── describe("SVG sanitizer -- XInclude", () => { it("strips xi:include elements and namespace", () => { const svg = wrapSvg( '', 'xmlns:xi="http://www.w3.org/2001/XInclude"', ); const result = sanitize(svg); expect(result).not.toContain("xi:include"); expect(result).not.toContain("xmlns:xi"); expect(result).not.toContain("file:///etc/passwd"); }); }); // ── External href ────────────────────────────────────────────────────── describe("SVG sanitizer -- external href", () => { it("removes with external xlink:href", () => { const svg = wrapSvg( '', 'xmlns:xlink="http://www.w3.org/1999/xlink"', ); const result = sanitize(svg); expect(result).not.toContain("evil.com"); expect(result).not.toContain(" with external href (no xlink)", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).not.toContain("evil.com"); expect(result).not.toContain(" with internal fragment reference", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).toContain(" { it("strips elements that inject event handlers", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).not.toContain(" elements with javascript: values", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).not.toContain(" { it("strips with external HTTP href (cloud metadata SSRF)", () => { const svg = wrapSvg( '' + '' + "" + '', ); const result = sanitize(svg); expect(result).not.toContain("169.254.169.254"); expect(result).not.toContain(" with HTTPS external href", () => { const svg = wrapSvg( '' + '' + "", ); const result = sanitize(svg); expect(result).not.toContain("evil.com"); expect(result).not.toContain(" with file: href", () => { const svg = wrapSvg( '' + '' + "", ); const result = sanitize(svg); expect(result).not.toContain("file:///etc/passwd"); expect(result).not.toContain(" with data: href", () => { const svg = wrapSvg( '' + '' + "", ); const result = sanitize(svg); expect(result).not.toMatch(/]*data:text\/html/i); }); it("strips with xlink:href external URL", () => { const svg = wrapSvg( '' + '' + "", 'xmlns:xlink="http://www.w3.org/1999/xlink"', ); const result = sanitize(svg); expect(result).not.toContain("169.254.169.254"); expect(result).not.toContain(" with internal fragment reference", () => { const svg = wrapSvg( '' + '' + '', ); const result = sanitize(svg); expect(result).toContain(" { it("blocks data: scheme inside url() property values", () => { const svg = wrapSvg( '', ); const result = sanitize(svg); expect(result).not.toMatch(/url\s*\(\s*["']?data:text\/html/i); }); }); // ── href scheme obfuscation: whitespace + unquoted (defense-in-depth) ──────── describe("SVG sanitizer -- href scheme whitespace/unquoted bypass", () => { it("blocks an unquoted javascript: URI in href", () => { const svg = wrapSvg("x"); const result = sanitize(svg); expect(result).not.toContain("javascript:"); }); it("blocks a javascript: URI with leading whitespace inside quotes", () => { const svg = wrapSvg('x'); const result = sanitize(svg); expect(result).not.toContain("javascript:"); }); it("blocks javascript: on xlink:href", () => { const svg = wrapSvg( 'x', 'xmlns:xlink="http://www.w3.org/1999/xlink"', ); const result = sanitize(svg); expect(result).not.toContain("javascript:"); }); }); // ── Extended animation / event elements ────────────────────────────────────── describe("SVG sanitizer -- extended animation elements", () => { it("strips with a javascript: value", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).not.toContain(" and its ", () => { const svg = wrapSvg(''); const result = sanitize(svg); expect(result).not.toContain(" SVG-Tiny event-handler element", () => { const svg = wrapSvg( 'alert(1)', 'xmlns:ev="http://www.w3.org/2001/xml-events"', ); const result = sanitize(svg); expect(result).not.toContain(" { it("preserves a minimal clean SVG unchanged", () => { const clean = ''; const result = sanitize(clean); expect(result).toBe(clean); }); it("preserves internal CSS styles", () => { const clean = ''; const result = sanitize(clean); expect(result).toContain("