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