diff --git a/ui/src/ui/mermaid.test.ts b/ui/src/ui/mermaid.test.ts index 5f978dc0134..f403b59a9e4 100644 --- a/ui/src/ui/mermaid.test.ts +++ b/ui/src/ui/mermaid.test.ts @@ -1,5 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { installMermaidInteractions } from "./mermaid.ts"; +import { installMermaidInteractions, sanitizeMermaidSvg } from "./mermaid.ts"; + +describe("sanitizeMermaidSvg", () => { + it("keeps safe HTML labels inside foreignObject", () => { + const sanitized = sanitizeMermaidSvg( + '
hello
world
', + ); + + expect(sanitized).toContain("foreignObject"); + expect(sanitized).toContain(""); + }); + + it("strips unsafe HTML labels inside foreignObject", () => { + const sanitized = sanitizeMermaidSvg( + '
hello
', + ); + + expect(sanitized).toContain("foreignObject"); + expect(sanitized).toContain("hello"); + expect(sanitized).not.toContain(" { beforeEach(() => { diff --git a/ui/src/ui/mermaid.ts b/ui/src/ui/mermaid.ts index 3d7a96d5389..5e2004772c4 100644 --- a/ui/src/ui/mermaid.ts +++ b/ui/src/ui/mermaid.ts @@ -9,6 +9,11 @@ let mermaidApiPromise: Promise | null = null; let renderScheduled = false; let renderCounter = 0; const interactionDocs = new WeakSet(); +const mermaidSanitizeOptions = { + ADD_TAGS: ["foreignObject", "foreignobject", "div", "span", "p", "br"], + ADD_ATTR: ["xmlns", "style", "class", "width", "height", "x", "y", "transform"], + HTML_INTEGRATION_POINTS: { foreignobject: true }, +}; async function loadMermaidApi(): Promise { if (!mermaidApiPromise) { @@ -18,10 +23,6 @@ async function loadMermaidApi(): Promise { api.initialize({ startOnLoad: false, securityLevel: "strict", - // Keep labels as native SVG text instead of foreignObject-backed HTML. - // The rendered SVG is sanitized before insertion, and HTML labels can - // lose their contents during that pass, leaving empty shapes behind. - htmlLabels: false, }); return api; }) @@ -117,6 +118,10 @@ function closeMermaidDialog(dialog: HTMLDialogElement | null): void { dialog.removeAttribute("open"); } +export function sanitizeMermaidSvg(svg: string): string { + return DOMPurify.sanitize(svg, mermaidSanitizeOptions); +} + export function scheduleMermaidRender(root: ParentNode = document): void { installMermaidInteractions(root); if (renderScheduled) { @@ -168,9 +173,7 @@ async function renderMermaidBlocks(root: ParentNode): Promise { try { const id = `openclaw-mermaid-${++renderCounter}`; const { svg } = await api.render(id, definition); - renderTarget.innerHTML = DOMPurify.sanitize(svg, { - USE_PROFILES: { svg: true, svgFilters: true }, - }); + renderTarget.innerHTML = sanitizeMermaidSvg(svg); block.dataset.mermaidStatus = "ready"; } catch (err) { console.warn("[markdown] mermaid render failed", err);