feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 39d9802daa
97 changed files with 20702 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
import DOMPurify from "dompurify";
import { parse as parseCss, walk as walkCss } from "css-tree";
import { defaultSvgLimits } from "../app/limits";
import type {
SemanticSvgDocument,
SemanticSvgNode,
} from "../document/document.types";
import type { SvgProjection, SvgSecurityFinding } from "./security.types";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const BLOCKED_ELEMENTS = new Set([
"script",
"foreignObject",
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const UNSAFE_CSS_PROPERTIES = new Set(["behavior", "-moz-binding"]);
const UNSAFE_CSS_FUNCTIONS = new Set([
"cross-fade",
"image",
"image-set",
"-webkit-image-set",
"paint",
"src",
]);
const URL_ATTRIBUTES = new Set([
"href",
"xlink:href",
"src",
"fill",
"stroke",
"filter",
"clip-path",
"mask",
"marker-start",
"marker-mid",
"marker-end",
"cursor",
]);
const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,/iu;
const LOCAL_FRAGMENT = /^#[A-Za-z_][A-Za-z0-9_.:-]*$/u;
const URL_FUNCTION = /url\(\s*(["']?)(.*?)\1\s*\)/giu;
function finding(
node: SemanticSvgNode,
code: string,
description: string,
action: SvgSecurityFinding["editingProjectionAction"],
severity: SvgSecurityFinding["severity"] = "error",
attribute?: string,
): SvgSecurityFinding {
return {
severity,
code,
nodeKey: node.key,
sourceRange:
(attribute ? node.attributeRanges[attribute]?.fullRange : undefined) ??
node.sourceRange.openTag,
description,
sourcePreserved: true,
editingProjectionAction: action,
};
}
function isUniqueLocalFragment(
value: string,
idCounts: ReadonlyMap<string, number>,
): boolean {
const target = value.trim();
return LOCAL_FRAGMENT.test(target) && idCounts.get(target.slice(1)) === 1;
}
function isSafeLocalReference(
value: string,
idCounts: ReadonlyMap<string, number>,
): boolean {
if (isUniqueLocalFragment(value, idCounts)) return true;
let safe = true;
let encountered = false;
value.replace(URL_FUNCTION, (_match, _quote: string, target: string) => {
encountered = true;
if (!isUniqueLocalFragment(target, idCounts)) safe = false;
return "";
});
return encountered && safe;
}
interface CssPolicyResult {
code: string;
description: string;
}
function inspectCssPolicy(
css: string,
context: "stylesheet" | "declarationList",
idCounts: ReadonlyMap<string, number>,
): CssPolicyResult | null {
let parseFailed = false;
let issue: CssPolicyResult | null = null;
try {
const ast = parseCss(css, {
context,
positions: false,
onParseError: () => {
parseFailed = true;
},
});
walkCss(ast, (node) => {
if (issue) return;
if (node.type === "Atrule" && node.name.toLowerCase() === "import") {
issue = {
code: "css-import",
description: "CSS @import is removed from the projection.",
};
} else if (
node.type === "Declaration" &&
UNSAFE_CSS_PROPERTIES.has(node.property.toLowerCase())
) {
issue = {
code: "css-behavior",
description: `CSS property “${node.property}” is not allowed in the projection.`,
};
} else if (
node.type === "Url" &&
!isUniqueLocalFragment(node.value, idCounts)
) {
issue = {
code: "css-external-url",
description:
"CSS URLs must resolve to one unique local fragment in the projection.",
};
} else if (
node.type === "Function" &&
node.name.toLowerCase() === "expression"
) {
issue = {
code: "css-expression",
description:
"CSS expression-like functions are not allowed in the projection.",
};
} else if (
node.type === "Function" &&
UNSAFE_CSS_FUNCTIONS.has(node.name.toLowerCase())
) {
issue = {
code: "css-resource-function",
description: `CSS function “${node.name}()” can resolve external resources and is not allowed in the projection.`,
};
}
});
} catch {
parseFailed = true;
}
return (
issue ??
(parseFailed
? {
code: "css-parser-error",
description:
"CSS could not be parsed safely and is removed from the projection.",
}
: null)
);
}
function inspectNode(
node: SemanticSvgNode,
idCounts: ReadonlyMap<string, number>,
): SvgSecurityFinding[] {
const findings: SvgSecurityFinding[] = [];
if (node.namespaceUri !== null && node.namespaceUri !== SVG_NAMESPACE) {
findings.push(
finding(
node,
"unknown-namespace",
`Element namespace “${node.namespaceUri}” is not rendered.`,
"removed",
),
);
}
if (BLOCKED_ELEMENTS.has(node.localName)) {
findings.push(
finding(
node,
`blocked-${node.localName.toLowerCase()}`,
`<${node.localName}> is preserved in source but removed from all rendered projections.`,
"removed",
),
);
}
for (const [name, value] of Object.entries(node.attributes)) {
const lowerName = name.toLowerCase();
if (lowerName.startsWith("on")) {
findings.push(
finding(
node,
"event-handler",
`Executable event attribute “${name}” is removed from the projection.`,
"removed",
"error",
name,
),
);
continue;
}
if (URL_ATTRIBUTES.has(lowerName)) {
const trimmed = value.trim();
const imageDataAllowed =
node.localName === "image" &&
(lowerName === "href" || lowerName === "xlink:href") &&
SAFE_IMAGE_DATA.test(trimmed) &&
trimmed.length <= defaultSvgLimits.maximumEmbeddedResourceBytes * 1.4;
const localAllowed = isSafeLocalReference(trimmed, idCounts);
const plainPaint =
["fill", "stroke"].includes(lowerName) && !/url\s*\(/iu.test(trimmed);
if (
!imageDataAllowed &&
!localAllowed &&
!plainPaint &&
trimmed !== "none"
) {
findings.push(
finding(
node,
"unsafe-url",
`External or executable URL in “${name}” is neutralized in the projection.`,
"neutralized",
"error",
name,
),
);
}
}
const cssIssue =
lowerName === "style"
? inspectCssPolicy(value, "declarationList", idCounts)
: null;
if (cssIssue) {
findings.push(
finding(
node,
"unsafe-inline-css",
cssIssue.description,
"removed",
"error",
name,
),
);
}
}
if (node.localName === "style") {
const cssIssue = inspectCssPolicy(node.text, "stylesheet", idCounts);
if (cssIssue) {
findings.push(
finding(node, "unsafe-stylesheet", cssIssue.description, "removed"),
);
}
}
return findings;
}
export function inspectSvgSecurity(
semantic: SemanticSvgDocument,
): SvgSecurityFinding[] {
const idCounts = new Map<string, number>();
for (const node of semantic.nodes.values()) {
if (node.id) idCounts.set(node.id, (idCounts.get(node.id) ?? 0) + 1);
}
const findings = semantic.order.flatMap((key) =>
inspectNode(semantic.nodes.get(key)!, idCounts),
);
if (/<!DOCTYPE\b/iu.test(semantic.source)) {
findings.push({
severity: "warning",
code: "doctype-ignored",
description:
"DOCTYPE remains in source but is excluded from rendering and no external entity is resolved.",
sourcePreserved: true,
editingProjectionAction: "removed",
});
}
return findings;
}
function matchingSemanticNode(
element: Element,
order: readonly string[],
semantic: SemanticSvgDocument,
index: number,
): SemanticSvgNode | null {
const exact = semantic.nodes.get(order[index] ?? "");
if (exact?.localName === element.localName) return exact;
const id = element.getAttribute("id");
if (id) {
return (
[...semantic.nodes.values()].find((candidate) => candidate.id === id) ??
null
);
}
return exact ?? null;
}
function postSanitize(
document: XMLDocument,
findings: readonly SvgSecurityFinding[],
): void {
const findingsByNode = new Map<string, SvgSecurityFinding[]>();
for (const item of findings) {
if (!item.nodeKey) continue;
const existing = findingsByNode.get(item.nodeKey) ?? [];
existing.push(item);
findingsByNode.set(item.nodeKey, existing);
}
const idCounts = new Map<string, number>();
for (const element of Array.from(document.querySelectorAll("[id]"))) {
const id = element.getAttribute("id");
if (id) idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
}
for (const element of Array.from(document.querySelectorAll("*"))) {
const nodeKey = element.getAttribute("data-svg-tools-node");
if (nodeKey && findingsByNode.has(nodeKey)) {
const nodeFindings = findingsByNode.get(nodeKey)!;
if (
nodeFindings.some(
(item) =>
item.code.startsWith("blocked-") ||
item.code === "unknown-namespace",
)
) {
element.remove();
continue;
}
for (const attribute of Array.from(element.attributes)) {
const lower = attribute.name.toLowerCase();
if (lower.startsWith("on")) element.removeAttribute(attribute.name);
if (
URL_ATTRIBUTES.has(lower) &&
!isSafeLocalReference(attribute.value, idCounts) &&
!(
element.localName === "image" &&
SAFE_IMAGE_DATA.test(attribute.value) &&
attribute.value.length <=
defaultSvgLimits.maximumEmbeddedResourceBytes * 1.4
) &&
!(
["fill", "stroke"].includes(lower) &&
!/url\s*\(/iu.test(attribute.value)
)
) {
element.removeAttribute(attribute.name);
}
}
if (
/unsafe-inline-css|unsafe-stylesheet/u.test(
nodeFindings.map((item) => item.code).join(" "),
)
) {
element.removeAttribute("style");
if (element.localName === "style") element.remove();
}
}
if (element.localName === "a") {
element.removeAttribute("href");
element.removeAttribute("xlink:href");
element.removeAttribute("target");
}
}
}
export function createEditingProjection(
semantic: SemanticSvgDocument,
): SvgProjection {
const sourceDocument = semantic.document.cloneNode(true) as XMLDocument;
const elements = [
sourceDocument.documentElement,
...Array.from(sourceDocument.documentElement.querySelectorAll("*")),
];
elements.forEach((element, index) => {
const node = matchingSemanticNode(element, semantic.order, semantic, index);
if (node) element.setAttribute("data-svg-tools-node", node.key);
});
const findings = inspectSvgSecurity(semantic);
const originalElementCount = elements.length;
const purified = DOMPurify.sanitize(
new XMLSerializer().serializeToString(sourceDocument.documentElement),
{
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: [...BLOCKED_ELEMENTS],
ALLOW_DATA_ATTR: true,
RETURN_DOM: false,
},
);
const projectionDocument = new DOMParser().parseFromString(
purified,
"image/svg+xml",
);
if (projectionDocument.documentElement.localName !== "svg") {
throw new Error("Sanitizer did not produce an SVG root");
}
postSanitize(projectionDocument, findings);
const projectionElementCount =
1 + projectionDocument.documentElement.querySelectorAll("*").length;
return {
source: new XMLSerializer().serializeToString(
projectionDocument.documentElement,
),
findings,
removedCount: Math.max(0, originalElementCount - projectionElementCount),
policy: "editing-projection-v1",
};
}
export function createSanitizedCandidate(
semantic: SemanticSvgDocument,
): SvgProjection {
const projection = createEditingProjection(semantic);
const document = new DOMParser().parseFromString(
projection.source,
"image/svg+xml",
);
for (const element of Array.from(
document.querySelectorAll("[data-svg-tools-node]"),
)) {
element.removeAttribute("data-svg-tools-node");
}
return {
...projection,
source: new XMLSerializer().serializeToString(document.documentElement),
};
}
+18
View File
@@ -0,0 +1,18 @@
import type { SourceRange } from "../document/document.types";
export interface SvgSecurityFinding {
severity: "info" | "warning" | "error";
code: string;
nodeKey?: string;
sourceRange?: SourceRange;
description: string;
sourcePreserved: boolean;
editingProjectionAction: "allowed" | "neutralized" | "removed" | "replaced";
}
export interface SvgProjection {
source: string;
findings: SvgSecurityFinding[];
removedCount: number;
policy: "editing-projection-v1";
}