fix(security): security audit and hardening (#207)
* fix(security): harden SVG sanitizer, rate limiting, and analytics defaults - SVG: add control-char stripping in href values to block whitespace/null-byte obfuscated javascript: URIs; block <feImage> with external href (SSRF via SVG filter primitives); expand test suite to 32 inline bypass payloads - Rate limiting: add per-route limits on tool endpoints (60/min) and batch (20/min); fix compose files defaulting RATE_LIMIT_PER_MIN to 0 which mapped to 50,000 in code; simplify rate limit registration to use env.ts default - Analytics: default ANALYTICS_ENABLED to false so self-hosters do not unknowingly send telemetry - Docker: add --max-time 5 and -s flags to compose healthcheck curl commands * fix: remove stale login limit bypass, reduce error log noise, clean up fixtures - Fix getLoginAttemptLimit() ignoring LOGIN_ATTEMPT_LIMIT when global rate limit exceeded 1000/min, which let the global limit override the stricter per-route login brute-force protection - Downgrade rate limit 429 responses from error to warn level in the global error handler to avoid log noise and unnecessary Sentry reports - Log 4xx client errors at warn level instead of error level - Remove 11 orphaned SVG attack fixture files replaced by inline test payloads
@@ -144,12 +144,16 @@ app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, b
|
||||
|
||||
app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
request.log.error(
|
||||
{ err: error, url: request.url, method: request.method },
|
||||
"Unhandled request error",
|
||||
);
|
||||
if (statusCode >= 500) {
|
||||
if (statusCode === 429) {
|
||||
request.log.warn({ url: request.url, method: request.method }, "Rate limit exceeded");
|
||||
} else if (statusCode >= 500) {
|
||||
request.log.error(
|
||||
{ err: error, url: request.url, method: request.method },
|
||||
"Unhandled request error",
|
||||
);
|
||||
captureException(error, request);
|
||||
} else {
|
||||
request.log.warn({ err: error, url: request.url, method: request.method }, "Request error");
|
||||
}
|
||||
reply.status(statusCode).send({
|
||||
error: statusCode >= 500 ? "Internal server error" : error.message,
|
||||
@@ -178,9 +182,8 @@ app.addHook("onSend", async (_request, reply) => {
|
||||
});
|
||||
|
||||
// Always register rate-limit plugin so per-route limits (login brute-force protection) work.
|
||||
// RATE_LIMIT_PER_MIN=0 means no global limit (per-route limits still apply).
|
||||
await app.register(rateLimit, {
|
||||
max: env.RATE_LIMIT_PER_MIN > 0 ? env.RATE_LIMIT_PER_MIN : 50_000,
|
||||
max: env.RATE_LIMIT_PER_MIN,
|
||||
timeWindow: "1 minute",
|
||||
allowList: (request) => !request.url.startsWith("/api/"),
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ const envSchema = z
|
||||
COOKIE_SECRET: z.string().default(""),
|
||||
ANALYTICS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0),
|
||||
POSTHOG_API_KEY: z.string().default("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"),
|
||||
|
||||
@@ -28,6 +28,15 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||
// Decode numeric entities so obfuscated URIs (e.g. javascript:) are visible.
|
||||
svg = decodeNumericEntities(svg);
|
||||
|
||||
// ── Pre-processing: normalize whitespace/null bytes inside href values ──
|
||||
// Catches obfuscated schemes like "java\nscript:", "java\x00script:", "java\tscript:"
|
||||
// by stripping control characters (0x00-0x1F) and DEL (0x7F) from href attribute values.
|
||||
svg = svg.replace(/((?:xlink:)?href\s*=\s*["'])([^"']*)(["'])/gi, (_m, prefix, value, suffix) => {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control-char stripping for security
|
||||
const cleaned = value.replace(/[\x00-\x1f\x7f]/g, "");
|
||||
return `${prefix}${cleaned}${suffix}`;
|
||||
});
|
||||
|
||||
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
||||
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
|
||||
// Remove XML processing instructions except <?xml version...?>
|
||||
@@ -65,6 +74,12 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||
svg = svg.replace(/<use\b[^>]*href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
|
||||
svg = svg.replace(/<use\b[^>]*xlink:href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
|
||||
|
||||
// ── Block <feImage> with external href (SSRF via SVG filter) ──
|
||||
svg = svg.replace(/<feImage\b[^>]*href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
|
||||
svg = svg.replace(/<feImage\b[^>]*xlink:href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
|
||||
svg = svg.replace(/<feImage\b[^>]*href\s*=\s*["']file:[^"']*["'][^>]*\/?>/gi, "");
|
||||
svg = svg.replace(/<feImage\b[^>]*href\s*=\s*["']data:[^"']*["'][^>]*\/?>/gi, "");
|
||||
|
||||
// ── Block dangerous URI schemes in href attributes ──
|
||||
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
|
||||
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
|
||||
|
||||
@@ -184,7 +184,6 @@ export async function ensureDefaultAdmin(): Promise<void> {
|
||||
// ── Login attempt limit ──────────────────────────────────────────
|
||||
|
||||
function getLoginAttemptLimit(): number {
|
||||
if (env.RATE_LIMIT_PER_MIN > 1000) return env.RATE_LIMIT_PER_MIN;
|
||||
const row = db
|
||||
.select()
|
||||
.from(schema.settings)
|
||||
|
||||
@@ -34,6 +34,7 @@ interface ParsedFile {
|
||||
export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
"/api/v1/tools/:toolId/batch",
|
||||
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => {
|
||||
const { toolId } = request.params;
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
|
||||
app.post(
|
||||
`/api/v1/tools/${config.toolId}`,
|
||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
|
||||
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
|
||||
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-0}
|
||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-0}
|
||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000}
|
||||
- MAX_USERS=${MAX_USERS:-0}
|
||||
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
|
||||
- TRUST_PROXY=${TRUST_PROXY:-true}
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
# writing to /etc/passwd and /etc/group. Consider using Docker --user flag
|
||||
# instead of PUID/PGID for read-only rootfs support.
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
|
||||
test: ["CMD", "curl", "-sf", "--max-time", "5", "http://localhost:1349/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 60s
|
||||
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
- MAX_WORKER_THREADS=${MAX_WORKER_THREADS:-0}
|
||||
- PROCESSING_TIMEOUT_S=${PROCESSING_TIMEOUT_S:-0}
|
||||
- MAX_PIPELINE_STEPS=${MAX_PIPELINE_STEPS:-0}
|
||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-0}
|
||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-1000}
|
||||
- MAX_USERS=${MAX_USERS:-0}
|
||||
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
|
||||
- TRUST_PROXY=${TRUST_PROXY:-true}
|
||||
@@ -80,7 +80,7 @@ services:
|
||||
# writing to /etc/passwd and /etc/group. Consider using Docker --user flag
|
||||
# instead of PUID/PGID for read-only rootfs support.
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
|
||||
test: ["CMD", "curl", "-sf", "--max-time", "5", "http://localhost:1349/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 60s
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a href="#">
|
||||
<animate attributeName="href" values="javascript:alert(1)" dur="0s" fill="freeze"/>
|
||||
<rect width="100" height="100"/>
|
||||
</a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 195 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<script><![CDATA[alert(document.cookie)]]></script>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 102 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a href="data:text/html,<script>alert(1)</script>">
|
||||
<rect width="100" height="100"/>
|
||||
</a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 146 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<a href="javascript:alert(1)">
|
||||
<rect width="100" height="100"/>
|
||||
</a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 176 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<foreignObject width="100" height="100">
|
||||
<body xmlns="http://www.w3.org/1999/xhtml">
|
||||
<script>alert(1)</script>
|
||||
</body>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 202 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100">
|
||||
<set attributeName="onmouseover" to="alert(1)"/>
|
||||
</rect>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 145 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xi="http://www.w3.org/2001/XInclude">
|
||||
<xi:include href="file:///etc/passwd" parse="text"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 146 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect width="10" height="10"/></svg>
|
||||
|
Before Width: | Height: | Size: 95 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>
|
||||
|
Before Width: | Height: | Size: 72 B |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg [
|
||||
<!ENTITY xxe SYSTEM "file:///etc/passwd">
|
||||
]>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<text>&xxe;</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 171 B |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg [
|
||||
<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/">
|
||||
]>
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<text>&xxe;</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 193 B |
@@ -28,10 +28,10 @@ describe("analytics env var validation", () => {
|
||||
|
||||
// ── ANALYTICS_ENABLED ───────────────────────────────────────────────────
|
||||
|
||||
it("ANALYTICS_ENABLED defaults to true", async () => {
|
||||
it("ANALYTICS_ENABLED defaults to false", async () => {
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.ANALYTICS_ENABLED).toBe(true);
|
||||
expect(env.ANALYTICS_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("ANALYTICS_ENABLED='false' transforms to boolean false", async () => {
|
||||
|
||||
@@ -153,7 +153,9 @@ function makeMockConfig(
|
||||
function createMockApp() {
|
||||
const routes: Record<string, (req: unknown, reply: unknown) => Promise<unknown>> = {};
|
||||
return {
|
||||
post: vi.fn((path: string, handler: (req: unknown, reply: unknown) => Promise<unknown>) => {
|
||||
post: vi.fn((...args: unknown[]) => {
|
||||
const path = args[0] as string;
|
||||
const handler = args[args.length - 1] as (req: unknown, reply: unknown) => Promise<unknown>;
|
||||
routes[path] = handler;
|
||||
}),
|
||||
routes,
|
||||
@@ -235,7 +237,11 @@ describe("createToolRoute", () => {
|
||||
const app = createMockApp();
|
||||
const id = uniqueId();
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
expect(app.post).toHaveBeenCalledWith(`/api/v1/tools/${id}`, expect.any(Function));
|
||||
expect(app.post).toHaveBeenCalledWith(
|
||||
`/api/v1/tools/${id}`,
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the tool config to the registry", () => {
|
||||
|
||||
@@ -1,111 +1,330 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
/**
|
||||
* 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";
|
||||
|
||||
const FIXTURES_DIR = join(__dirname, "../fixtures/security");
|
||||
|
||||
function loadFixture(name: string): Buffer {
|
||||
return readFileSync(join(FIXTURES_DIR, name));
|
||||
/** Wrap a payload fragment inside a minimal valid SVG. */
|
||||
function wrapSvg(inner: string, attrs = ""): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg"${attrs ? ` ${attrs}` : ""}>${inner}</svg>`;
|
||||
}
|
||||
|
||||
function sanitize(name: string): string {
|
||||
return sanitizeSvg(loadFixture(name)).toString("utf-8");
|
||||
/** 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");
|
||||
}
|
||||
|
||||
describe("SVG sanitizer -- attack payload fixtures", () => {
|
||||
it("strips <script> tags (svg-xss-script.svg)", () => {
|
||||
const result = sanitize("svg-xss-script.svg");
|
||||
// ── XSS: Script Injection ────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- XSS script injection", () => {
|
||||
it("strips standard <script> tags", () => {
|
||||
const svg = wrapSvg("<script>alert(1)</script>");
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("</script>");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
// SVG wrapper should survive
|
||||
expect(result).toContain("<svg");
|
||||
});
|
||||
|
||||
it("neutralizes event handler attributes (svg-xss-event-handler.svg)", () => {
|
||||
const result = sanitize("svg-xss-event-handler.svg");
|
||||
expect(result).not.toMatch(/\bonload\s*=/i);
|
||||
// The event handler is replaced with data-removed="" (value stripped)
|
||||
expect(result).toContain('data-removed=""');
|
||||
// The payload text should not appear in any executable context
|
||||
expect(result).not.toMatch(/on\w+\s*=\s*["']alert/i);
|
||||
it("strips case-varied <SCRIPT> tags", () => {
|
||||
const svg = wrapSvg("<SCRIPT>alert(1)</SCRIPT>");
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/<script/i);
|
||||
expect(result).not.toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("removes DOCTYPE with XXE file-read entity (svg-xxe-file-read.svg)", () => {
|
||||
const result = sanitize("svg-xxe-file-read.svg");
|
||||
it("strips nested SVG with script", () => {
|
||||
const svg = wrapSvg("<svg><svg><script>alert(1)</script></svg></svg>");
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
});
|
||||
});
|
||||
|
||||
// ── XSS: Event Handlers ─────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- event handler injection", () => {
|
||||
it("removes onload event handler", () => {
|
||||
const svg = wrapSvg('<rect width="10" height="10" onload="alert(1)"/>');
|
||||
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("<script><![CDATA[alert(1)]]></script>");
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("CDATA");
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
});
|
||||
});
|
||||
|
||||
// ── XXE: External Entity Attacks ─────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- XXE attacks", () => {
|
||||
it("removes DOCTYPE with file-read XXE entity", () => {
|
||||
const svg =
|
||||
'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>' +
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>';
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/<!DOCTYPE/i);
|
||||
expect(result).not.toContain("file:///etc/passwd");
|
||||
});
|
||||
|
||||
it("removes DOCTYPE with XXE SSRF entity (svg-xxe-ssrf.svg)", () => {
|
||||
const result = sanitize("svg-xxe-ssrf.svg");
|
||||
it("removes DOCTYPE with SSRF XXE entity", () => {
|
||||
const svg =
|
||||
'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "http://169.254.169.254/">]>' +
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>';
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/<!DOCTYPE/i);
|
||||
expect(result).not.toContain("169.254.169.254");
|
||||
});
|
||||
|
||||
it("strips foreignObject and embedded script (svg-foreign-object.svg)", () => {
|
||||
const result = sanitize("svg-foreign-object.svg");
|
||||
it("removes XML entity bypass with entity-encoded javascript URI", () => {
|
||||
// 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 =
|
||||
'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x "javascript:alert(1)">]>' +
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><a href="&x;"><text>click</text></a></svg>';
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/<!DOCTYPE/i);
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
});
|
||||
|
||||
// ── foreignObject ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- foreignObject", () => {
|
||||
it("strips foreignObject with embedded HTML body", () => {
|
||||
const svg = wrapSvg(
|
||||
'<foreignObject width="100" height="100">' +
|
||||
'<body xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></body>' +
|
||||
"</foreignObject>",
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("<foreignObject");
|
||||
expect(result).not.toContain("</foreignObject>");
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("blocks data: URI in href (svg-data-uri.svg)", () => {
|
||||
const result = sanitize("svg-data-uri.svg");
|
||||
// The data:text/html payload should be neutralized
|
||||
it("strips mixed-case <ForeignObject> variant", () => {
|
||||
const svg = wrapSvg("<ForeignObject><body>malicious</body></ForeignObject>");
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/<foreignObject/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── URI Scheme Attacks ───────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- dangerous URI schemes", () => {
|
||||
it("blocks data: URI in href", () => {
|
||||
const svg = wrapSvg(
|
||||
'<a href="data:text/html,<script>alert(1)</script>"><text>click</text></a>',
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/href\s*=\s*["']data:text\/html/i);
|
||||
expect(result).not.toContain("<script>alert(1)</script>");
|
||||
});
|
||||
|
||||
it("strips XInclude elements and namespace (svg-xinclude.svg)", () => {
|
||||
const result = sanitize("svg-xinclude.svg");
|
||||
it("blocks entity-encoded javascript: URI in href", () => {
|
||||
const svg = wrapSvg(
|
||||
'<a href="javascript:alert(1)">' +
|
||||
"<text>click</text></a>",
|
||||
);
|
||||
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('<a href="java\nscript:alert(1)"><text>click</text></a>');
|
||||
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('<a href="java\x00script:alert(1)"><text>click</text></a>');
|
||||
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('<a href="java\tscript:alert(1)"><text>click</text></a>');
|
||||
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(
|
||||
'<image href="data:text/html,<script>alert(1)</script>" width="100" height="100"/>',
|
||||
);
|
||||
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(
|
||||
'<xi:include href="file:///etc/passwd" parse="text"/>',
|
||||
'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");
|
||||
});
|
||||
});
|
||||
|
||||
it("strips CDATA sections to prevent script bypass (svg-cdata-bypass.svg)", () => {
|
||||
const result = sanitize("svg-cdata-bypass.svg");
|
||||
expect(result).not.toContain("CDATA");
|
||||
expect(result).not.toContain("alert(document.cookie)");
|
||||
// Script tags should also be removed
|
||||
expect(result).not.toContain("<script");
|
||||
// ── External <use> href ──────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- external <use> href", () => {
|
||||
it("removes <use> with external xlink:href", () => {
|
||||
const svg = wrapSvg(
|
||||
'<use xlink:href="http://evil.com/malicious.svg#payload"/>',
|
||||
'xmlns:xlink="http://www.w3.org/1999/xlink"',
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("evil.com");
|
||||
expect(result).not.toContain("<use");
|
||||
});
|
||||
|
||||
it("decodes entity-encoded javascript: URI and blocks it (svg-entity-bypass.svg)", () => {
|
||||
const result = sanitize("svg-entity-bypass.svg");
|
||||
// After entity decoding, javascript: should be caught and neutralized
|
||||
expect(result).not.toMatch(/href\s*=\s*["']javascript:/i);
|
||||
// The javascript: scheme must be gone (replaced with safe data:, prefix)
|
||||
expect(result).not.toContain("javascript:");
|
||||
it("removes <use> with external href (no xlink)", () => {
|
||||
const svg = wrapSvg('<use href="https://evil.com/malicious.svg#payload"/>');
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("evil.com");
|
||||
expect(result).not.toContain("<use");
|
||||
});
|
||||
|
||||
it("strips <animate> elements that inject URIs (svg-animate-inject.svg)", () => {
|
||||
const result = sanitize("svg-animate-inject.svg");
|
||||
expect(result).not.toContain("<animate");
|
||||
expect(result).not.toContain("javascript:alert(1)");
|
||||
it("preserves <use> with internal fragment reference", () => {
|
||||
const svg = wrapSvg('<defs><rect id="r" width="10" height="10"/></defs><use href="#r"/>');
|
||||
const result = sanitize(svg);
|
||||
expect(result).toContain("<use");
|
||||
expect(result).toContain('href="#r"');
|
||||
});
|
||||
});
|
||||
|
||||
it("strips <set> elements that inject attributes (svg-set-inject.svg)", () => {
|
||||
const result = sanitize("svg-set-inject.svg");
|
||||
// ── Animation Injection ──────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- animation injection", () => {
|
||||
it("strips <set> elements that inject event handlers", () => {
|
||||
const svg = wrapSvg('<set attributeName="onmouseover" to="alert(1)"/>');
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("<set");
|
||||
expect(result).not.toContain("onmouseover");
|
||||
});
|
||||
|
||||
it("strips <animate> elements with javascript: values", () => {
|
||||
const svg = wrapSvg('<animate attributeName="href" values="javascript:alert(1)"/>');
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("<animate");
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
});
|
||||
|
||||
// ── feImage SSRF ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- feImage SSRF", () => {
|
||||
it("strips <feImage> with external HTTP href (cloud metadata SSRF)", () => {
|
||||
const svg = wrapSvg(
|
||||
'<defs><filter id="f">' +
|
||||
'<feImage href="http://169.254.169.254/latest/meta-data/"/>' +
|
||||
"</filter></defs>" +
|
||||
'<rect filter="url(#f)" width="100" height="100"/>',
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("169.254.169.254");
|
||||
expect(result).not.toContain("<feImage");
|
||||
});
|
||||
|
||||
it("strips <feImage> with HTTPS external href", () => {
|
||||
const svg = wrapSvg(
|
||||
'<defs><filter id="f">' + '<feImage href="https://evil.com/exfil"/>' + "</filter></defs>",
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("evil.com");
|
||||
expect(result).not.toContain("<feImage");
|
||||
});
|
||||
|
||||
it("strips <feImage> with file: href", () => {
|
||||
const svg = wrapSvg(
|
||||
'<defs><filter id="f">' + '<feImage href="file:///etc/passwd"/>' + "</filter></defs>",
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("file:///etc/passwd");
|
||||
expect(result).not.toContain("<feImage");
|
||||
});
|
||||
|
||||
it("strips <feImage> with data: href", () => {
|
||||
const svg = wrapSvg(
|
||||
'<defs><filter id="f">' +
|
||||
'<feImage href="data:text/html,<script>alert(1)</script>"/>' +
|
||||
"</filter></defs>",
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/<feImage[^>]*data:text\/html/i);
|
||||
});
|
||||
|
||||
it("strips <feImage> with xlink:href external URL", () => {
|
||||
const svg = wrapSvg(
|
||||
'<defs><filter id="f">' +
|
||||
'<feImage xlink:href="http://169.254.169.254/latest/meta-data/"/>' +
|
||||
"</filter></defs>",
|
||||
'xmlns:xlink="http://www.w3.org/1999/xlink"',
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toContain("169.254.169.254");
|
||||
expect(result).not.toContain("<feImage");
|
||||
});
|
||||
|
||||
it("preserves <feImage> with internal fragment reference", () => {
|
||||
const svg = wrapSvg(
|
||||
'<defs><rect id="src" width="10" height="10" fill="red"/>' +
|
||||
'<filter id="f"><feImage href="#src"/></filter></defs>' +
|
||||
'<rect filter="url(#f)" width="100" height="100"/>',
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).toContain("<feImage");
|
||||
expect(result).toContain('href="#src"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── url() in style attributes ────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- url() scheme blocking", () => {
|
||||
it("blocks data: scheme inside url() property values", () => {
|
||||
const svg = wrapSvg(
|
||||
'<rect style="background:url(data:text/html,payload)" width="10" height="10"/>',
|
||||
);
|
||||
const result = sanitize(svg);
|
||||
expect(result).not.toMatch(/url\s*\(\s*["']?data:text\/html/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Clean SVGs pass through ──────────────────────────────────────────────────
|
||||
|
||||
describe("SVG sanitizer -- clean SVGs pass through", () => {
|
||||
it("preserves a minimal clean SVG unchanged", () => {
|
||||
const clean = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(clean)).toString("utf-8");
|
||||
const result = sanitize(clean);
|
||||
expect(result).toBe(clean);
|
||||
});
|
||||
|
||||
it("preserves internal CSS styles", () => {
|
||||
const clean =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><style>rect { fill: red; }</style><rect width="10" height="10"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(clean)).toString("utf-8");
|
||||
const result = sanitize(clean);
|
||||
expect(result).toContain("<style>");
|
||||
expect(result).toContain("fill: red");
|
||||
});
|
||||
@@ -113,58 +332,7 @@ describe("SVG sanitizer -- clean SVGs pass through", () => {
|
||||
it("preserves internal fragment href in <use>", () => {
|
||||
const clean =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><defs><rect id="r" width="10" height="10"/></defs><use href="#r"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(clean)).toString("utf-8");
|
||||
const result = sanitize(clean);
|
||||
expect(result).toContain('href="#r"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("SVG sanitizer -- data: in url()", () => {
|
||||
it("blocks data: scheme inside url() property values", () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill: url(data:image/svg+xml,<svg/>)"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(svg)).toString("utf-8");
|
||||
expect(result).not.toMatch(/url\s*\(\s*["']?data:image/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SVG sanitizer -- <use> with external href", () => {
|
||||
it("removes <use> elements referencing external HTTP URLs", () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><use href="https://evil.com/payload.svg#x"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(svg)).toString("utf-8");
|
||||
expect(result).not.toContain("evil.com");
|
||||
expect(result).not.toContain("<use");
|
||||
});
|
||||
|
||||
it("removes <use> elements referencing external xlink:href URLs", () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="http://evil.com/payload.svg#x"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(svg)).toString("utf-8");
|
||||
expect(result).not.toContain("<use");
|
||||
});
|
||||
|
||||
it("preserves <use> with internal fragment reference", () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><use href="#myShape"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(svg)).toString("utf-8");
|
||||
expect(result).toContain("<use");
|
||||
expect(result).toContain('href="#myShape"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("SVG sanitizer -- iframe/embed stripping", () => {
|
||||
it("strips <iframe> elements", () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><iframe src="https://evil.com"></iframe></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(svg)).toString("utf-8");
|
||||
expect(result).not.toContain("<iframe");
|
||||
expect(result).not.toContain("evil.com");
|
||||
});
|
||||
|
||||
it("strips <embed> elements", () => {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><embed src="https://evil.com" type="text/html"/></svg>';
|
||||
const result = sanitizeSvg(Buffer.from(svg)).toString("utf-8");
|
||||
expect(result).not.toContain("<embed");
|
||||
expect(result).not.toContain("evil.com");
|
||||
});
|
||||
});
|
||||
|
||||