Files
SnapOtter/tests/unit/security-svg-sanitize.test.ts
T
SnapOtter 4e64ee2779 fix(security): comprehensive security audit and hardening
Auth: login rate limit 30/min (was 500), global rate limit 1000/min (was
unlimited), password/username max lengths on all Zod schemas, session
invalidation on role change, API key legacy scan bounded to 100 keys.

SVG: hardened regex sanitizer with CDATA stripping, XML entity decoding,
set/animate/iframe/embed blocking, comprehensive data: URI blocking,
use element external href blocking. 11 attack payload fixtures added.

SSRF: fixed DNS rebinding TOCTOU by pinning resolved IPs via custom
HTTP/HTTPS agents. Added 6to4 and NAT64 to blocked IPv6 ranges.

Docker: capability dropping (cap_drop ALL + minimal cap_add), resource
limits (4g/8g mem, 512/1024 pids), healthcheck timeout, password
removed from startup banner, default password warning comments.

Network: CSP and HSTS applied in all environments (not just production),
stack traces removed from all error responses, internal paths stripped
from error details, per-route rate limits on uploads (60/min) and URL
fetches (200/hour).

Files: exclusive temp file creation (O_EXCL), disk space circuit
breaker, per-user storage quotas, settings payload 64KB size guard.

Python sidecar: script name allowlist in dispatcher, minimal environment
for subprocess spawns.

Dependencies: fixed 6 production CVEs (drizzle-orm, fastify, fast-uri,
@fastify/static, next, archiver/lodash). Pinned all GitHub Actions to
SHA hashes.

114 security tests added. Full OWASP Top 10 penetration test matrix
verified against production Docker container (30/30 pass after
hardening).
2026-05-13 21:33:50 +08:00

171 lines
6.9 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
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));
}
function sanitize(name: string): string {
return sanitizeSvg(loadFixture(name)).toString("utf-8");
}
describe("SVG sanitizer -- attack payload fixtures", () => {
it("strips <script> tags (svg-xss-script.svg)", () => {
const result = sanitize("svg-xss-script.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("removes DOCTYPE with XXE file-read entity (svg-xxe-file-read.svg)", () => {
const result = sanitize("svg-xxe-file-read.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");
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");
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
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");
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");
});
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("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("strips <set> elements that inject attributes (svg-set-inject.svg)", () => {
const result = sanitize("svg-set-inject.svg");
expect(result).not.toContain("<set");
expect(result).not.toContain("onmouseover");
});
});
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");
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");
expect(result).toContain("<style>");
expect(result).toContain("fill: red");
});
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");
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");
});
});