control-ui: keep Mermaid HTML labels

This commit is contained in:
chembo.huang 2026-03-20 10:02:37 +08:00
parent 636d4e9412
commit 2ce4f055f3
2 changed files with 34 additions and 8 deletions

View File

@ -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(
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml">hello<br/>world</div></foreignObject></svg>',
);
expect(sanitized).toContain("foreignObject");
expect(sanitized).toContain("<div");
expect(sanitized).toContain("hello");
expect(sanitized).toContain("<br>");
});
it("strips unsafe HTML labels inside foreignObject", () => {
const sanitized = sanitizeMermaidSvg(
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script>hello</div></foreignObject></svg>',
);
expect(sanitized).toContain("foreignObject");
expect(sanitized).toContain("hello");
expect(sanitized).not.toContain("<script");
});
});
describe("installMermaidInteractions", () => {
beforeEach(() => {

View File

@ -9,6 +9,11 @@ let mermaidApiPromise: Promise<MermaidApi> | null = null;
let renderScheduled = false;
let renderCounter = 0;
const interactionDocs = new WeakSet<Document>();
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<MermaidApi> {
if (!mermaidApiPromise) {
@ -18,10 +23,6 @@ async function loadMermaidApi(): Promise<MermaidApi> {
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<void> {
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);