From da89492f75233242c0a94ddea738bc1cdbae2bd3 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 28 Aug 2026 19:01:08 +0800 Subject: [PATCH] fix(web-ui): sanitize mermaid svg output before html injection Mermaid is configured with securityLevel: 'loose', so diagram labels may carry raw HTML through rendering. The rendered SVG was passed straight into dangerouslySetInnerHTML, letting a crafted diagram execute scripts, abuse on* event handlers, or navigate via javascript: URLs. Add sanitizeMermaidSvg.ts: an allowlist-based DOM sanitizer that keeps the structural SVG and text-container elements a rendered diagram needs while stripping script/iframe/object-style elements, on* handler attributes, and javascript:/vbscript:/data:text-html URL schemes. Wire it into MermaidBlock so only sanitized markup reaches the DOM, and cover the XSS vectors (script, on* handlers, unsafe URLs, foreignObject payloads) plus normal-diagram rendering in tests. Test: pnpm --dir src/web-ui run type-check; pnpm --dir src/web-ui run lint; vitest run MermaidBlock.test.tsx (6 passed); Markdown/MarkdownEditor regression suites (16 passed). AI: AI-assisted, locally tested (type-check + lint + targeted vitest). --- .../components/Markdown/MermaidBlock.test.tsx | 95 +++++++++++++ .../components/Markdown/MermaidBlock.tsx | 3 +- .../components/Markdown/sanitizeMermaidSvg.ts | 134 ++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/web-ui/src/component-library/components/Markdown/MermaidBlock.test.tsx create mode 100644 src/web-ui/src/component-library/components/Markdown/sanitizeMermaidSvg.ts diff --git a/src/web-ui/src/component-library/components/Markdown/MermaidBlock.test.tsx b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.test.tsx new file mode 100644 index 0000000000..b77bc0f383 --- /dev/null +++ b/src/web-ui/src/component-library/components/Markdown/MermaidBlock.test.tsx @@ -0,0 +1,95 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it } from 'vitest'; + +import { sanitizeMermaidSvg } from './sanitizeMermaidSvg'; + +describe('sanitizeMermaidSvg', () => { + describe('XSS vectors are removed', () => { + it('strips '; + const clean = sanitizeMermaidSvg(raw); + expect(clean).not.toContain(' { + const raw = ''; + const clean = sanitizeMermaidSvg(raw); + expect(clean).not.toContain('onload'); + expect(clean).not.toContain('onmouseover'); + expect(clean).not.toContain('alert(2)'); + expect(clean.toLowerCase()).toContain(' { + const raw = 'x'; + const clean = sanitizeMermaidSvg(raw); + expect(clean).not.toContain('javascript:'); + expect(clean.toLowerCase()).toContain(' { + const raw = + '' + + '
' + + '

label

'; + const clean = sanitizeMermaidSvg(raw); + expect(clean).not.toContain('onerror'); + expect(clean).not.toContain('onclick'); + expect(clean).not.toContain(' { + expect(sanitizeMermaidSvg('')).toBe(''); + expect(sanitizeMermaidSvg('
not an svg
')).toBe(''); + }); + }); + + describe('normal mermaid output is preserved', () => { + it('keeps structural svg elements and drops nothing that a rendered diagram needs', () => { + const raw = ` + + + + + + + + + + + + + Process + + + +

Hello

+
+
+
`; + const clean = sanitizeMermaidSvg(raw); + const lower = clean.toLowerCase(); + expect(lower).toContain(' = ({ className="mermaid-block__diagram" data-bf-component="mermaid-block" data-bf-part="diagram" - dangerouslySetInnerHTML={{ __html: svgContent }} + dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svgContent) }} />
diff --git a/src/web-ui/src/component-library/components/Markdown/sanitizeMermaidSvg.ts b/src/web-ui/src/component-library/components/Markdown/sanitizeMermaidSvg.ts new file mode 100644 index 0000000000..b6645570f5 --- /dev/null +++ b/src/web-ui/src/component-library/components/Markdown/sanitizeMermaidSvg.ts @@ -0,0 +1,134 @@ +/** + * Sanitizer for Mermaid-rendered SVG markup. + * + * Mermaid is initialized with `securityLevel: 'loose'`, which allows label + * markup (raw HTML in labels) to pass through rendering. A crafted diagram + * can therefore carry `