feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user