149 lines
5.8 KiB
TypeScript
149 lines
5.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseSvgSource } from "../../src/document/source-parser";
|
|
import {
|
|
createEditingProjection,
|
|
createSanitizedCandidate,
|
|
inspectSvgSecurity,
|
|
} from "../../src/security/sanitize-svg";
|
|
|
|
function parse(source: string) {
|
|
const result = parseSvgSource(source, 1);
|
|
expect(result.semantic).not.toBeNull();
|
|
return result.semantic!;
|
|
}
|
|
|
|
describe("hostile SVG editing projection", () => {
|
|
it("preserves active content in canonical source but removes it from projection", () => {
|
|
const semantic =
|
|
parse(`<svg xmlns="http://www.w3.org/2000/svg" onload="steal()">
|
|
<script>alert(1)</script>
|
|
<foreignObject><div xmlns="http://www.w3.org/1999/xhtml">HTML</div></foreignObject>
|
|
<a href="https://evil.example/"><rect width="10" height="10"/></a>
|
|
<image href="https://evil.example/tracker.png"/>
|
|
<path style="fill:url(https://evil.example/paint)" d="M0 0L1 1"/>
|
|
</svg>`);
|
|
const findings = inspectSvgSecurity(semantic);
|
|
const codes = findings.map((item) => item.code);
|
|
expect(codes).toEqual(
|
|
expect.arrayContaining([
|
|
"event-handler",
|
|
"blocked-script",
|
|
"blocked-foreignobject",
|
|
"unsafe-url",
|
|
]),
|
|
);
|
|
expect(codes.some((code) => code.startsWith("unsafe-inline-css"))).toBe(
|
|
true,
|
|
);
|
|
expect(semantic.source).toContain("steal()");
|
|
const projection = createEditingProjection(semantic);
|
|
expect(projection.source).not.toMatch(
|
|
/<script|foreignObject|steal\(\)|evil\.example/iu,
|
|
);
|
|
expect(projection.source).toContain("data-svg-tools-node");
|
|
});
|
|
|
|
it("allows local fragment paint and bounded embedded raster data", () => {
|
|
const semantic = parse(
|
|
`<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="p"/></defs><rect fill="url(#p)"/><image href="data:image/png;base64,iVBORw0KGgo="/></svg>`,
|
|
);
|
|
const projection = createEditingProjection(semantic);
|
|
expect(projection.source).toContain("url(#p)");
|
|
expect(projection.source).toContain("data:image/png;base64");
|
|
});
|
|
|
|
it("removes internal mapping metadata from explicit sanitized source", () => {
|
|
const semantic = parse(
|
|
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="c" r="2"/></svg>',
|
|
);
|
|
const candidate = createSanitizedCandidate(semantic);
|
|
expect(candidate.source).not.toContain("data-svg-tools-node");
|
|
expect(candidate.source).toContain('id="c"');
|
|
});
|
|
|
|
it("parses CSS and removes imports, external URLs and behavior-like properties", () => {
|
|
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
|
|
<style>@import/**/url(https://evil.example/a.css); .x { fill: red }</style>
|
|
<path class="x" style="-moz-binding:url(https://evil.example/x);fill:red" d="M0 0L1 1"/>
|
|
</svg>`);
|
|
const findings = inspectSvgSecurity(semantic);
|
|
expect(
|
|
findings.some((item) => item.code.startsWith("unsafe-stylesheet")),
|
|
).toBe(true);
|
|
expect(
|
|
findings.some((item) => item.code.startsWith("unsafe-inline-css")),
|
|
).toBe(true);
|
|
const projection = createEditingProjection(semantic);
|
|
expect(projection.source).not.toContain("evil.example");
|
|
expect(projection.source).not.toContain("-moz-binding");
|
|
});
|
|
|
|
it("blocks CSS resource functions even when a URL is represented as a string", () => {
|
|
const semantic = parse(
|
|
'<svg xmlns="http://www.w3.org/2000/svg"><style>.x { background-image: image-set("tracker.png" 1x) }</style><rect class="x" width="2" height="2"/></svg>',
|
|
);
|
|
const findings = inspectSvgSecurity(semantic);
|
|
|
|
expect(findings).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "unsafe-stylesheet",
|
|
description: expect.stringContaining("image-set()"),
|
|
}),
|
|
);
|
|
expect(createEditingProjection(semantic).source).not.toContain(
|
|
"tracker.png",
|
|
);
|
|
});
|
|
|
|
it("allows only references that resolve to one unique local ID", () => {
|
|
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
|
|
<defs><linearGradient id="unique"/><linearGradient id="duplicate"/><linearGradient id="duplicate"/></defs>
|
|
<rect id="ok" fill="url(#unique)"/>
|
|
<rect id="missing" fill="url(#absent)"/>
|
|
<rect id="ambiguous" fill="url(#duplicate)"/>
|
|
</svg>`);
|
|
const projection = createEditingProjection(semantic).source;
|
|
const document = new DOMParser().parseFromString(
|
|
projection,
|
|
"image/svg+xml",
|
|
);
|
|
expect(document.getElementById("ok")?.getAttribute("fill")).toBe(
|
|
"url(#unique)",
|
|
);
|
|
expect(document.getElementById("missing")?.hasAttribute("fill")).toBe(
|
|
false,
|
|
);
|
|
expect(document.getElementById("ambiguous")?.hasAttribute("fill")).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("keeps the normal canvas static by removing declarative animation elements", () => {
|
|
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
|
|
<circle id="dot" r="4"><animate attributeName="opacity" values="0;1" begin="click"/></circle>
|
|
<set attributeName="fill" to="red"/>
|
|
</svg>`);
|
|
const findings = inspectSvgSecurity(semantic);
|
|
expect(findings.map((item) => item.code)).toEqual(
|
|
expect.arrayContaining(["blocked-animate", "blocked-set"]),
|
|
);
|
|
const projection = createEditingProjection(semantic).source;
|
|
expect(projection).not.toMatch(/<animate|<set/iu);
|
|
expect(projection).toContain('id="dot"');
|
|
});
|
|
|
|
it("reports and removes elements in unknown namespaces", () => {
|
|
const semantic =
|
|
parse(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:evil="urn:evil">
|
|
<evil:widget id="active"><circle r="2"/></evil:widget>
|
|
</svg>`);
|
|
expect(inspectSvgSecurity(semantic)).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "unknown-namespace",
|
|
editingProjectionAction: "removed",
|
|
}),
|
|
);
|
|
expect(createEditingProjection(semantic).source).not.toContain("widget");
|
|
});
|
|
});
|