mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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).
171 lines
6.9 KiB
TypeScript
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");
|
|
});
|
|
});
|