fix(editor): render formatted Mermaid labels safely (#25554)

* fix(editor): render formatted Mermaid labels safely

Enable Mermaid HTML labels everywhere the desktop renders diagrams and
sanitize with foreignObject XHTML kept, form controls and SVG filters removed.

* fix(editor): copy HTML-label diagrams as PNG

Chromium taints a canvas drawn from a blob-URL SVG that holds foreignObject labels; the viewer now loads the diagram from a data URL.

* test(editor): share one Chromium across the Mermaid sanitizer suite instead of per-test timeouts
This commit is contained in:
Neil
2026-10-31 17:09:33 -07:00
committed by GitHub
parent 25660ff784
commit a75b2c77d4
12 changed files with 183 additions and 25 deletions
@@ -53,8 +53,6 @@ beforeEach(() => {
}
}
)
URL.createObjectURL = vi.fn(() => 'blob:diagram')
URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
@@ -73,7 +71,7 @@ describe('DiagramViewerModal', () => {
expect(screen.getByRole('dialog', { name: 'Diagram' })).toBeInTheDocument()
sizeCanvas()
const image = screen.getByRole('img', { name: 'Diagram' })
expect(image).toHaveAttribute('src', 'blob:diagram')
expect(image.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
expect(image.style.width).toBe('400px')
expect(image.style.transform).toBe('translate(300px, 200px)')
expect(screen.getByRole('button', { name: 'Fit to screen' })).toBeDisabled()
@@ -81,7 +79,6 @@ describe('DiagramViewerModal', () => {
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
await waitFor(() => expect(document.activeElement).toBe(opener))
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:diagram')
})
it('zooms from the toolbar, pans on drag, and fits again', async () => {
@@ -3,7 +3,6 @@ import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { PanZoomSize } from '../../../../shared/pan-zoom-view'
import { PanZoomImageDialog } from './PanZoomImageDialog'
import { useSvgObjectUrl } from './use-svg-object-url'
function readSvgImage(svg: string): { xml: string; size: PanZoomSize } | null {
// HTML-serialized sanitized SVG can contain  , which XML image documents reject.
@@ -27,7 +26,11 @@ export default function DiagramViewerModal(): React.JSX.Element {
const svg = typeof modalData.svg === 'string' ? modalData.svg : ''
const background = typeof modalData.background === 'string' ? modalData.background : undefined
const image = useMemo(() => readSvgImage(svg), [svg])
const url = useSvgObjectUrl(image?.xml ?? null)
// Chromium taints a canvas drawn from a blob-URL SVG with HTML labels, which breaks Copy as PNG; a data URL does not.
const url = useMemo(
() => (image ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.xml)}` : null),
[image]
)
return (
<PanZoomImageDialog
@@ -6,12 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mermaid = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn<(id: string, content: string) => Promise<{ svg: string }>>(),
sanitize: vi.fn<(svg: string) => string>()
sanitize: vi.fn<(svg: string, config?: unknown) => string>()
}))
vi.mock('mermaid', () => ({ default: mermaid }))
vi.mock('dompurify', () => ({ default: { sanitize: mermaid.sanitize } }))
import MermaidBlock from './MermaidBlock'
import { mermaidSvgSanitizeConfig } from './mermaid-sanitize'
const partial = 'flowchart TD\n A["Sync with main '
const complete = `${partial}once"]`
@@ -133,7 +134,7 @@ describe('MermaidBlock rendering', () => {
mermaid.sanitize.mockReturnValueOnce('<svg><text>Safe diagram</text></svg>')
const { container } = render(<MermaidBlock content={complete} isDark={false} />)
await screen.findByText('Safe diagram')
expect(mermaid.sanitize).toHaveBeenCalledWith(unsafeSvg, { USE_PROFILES: { svg: true } })
expect(mermaid.sanitize).toHaveBeenCalledWith(unsafeSvg, mermaidSvgSanitizeConfig)
expect(container.querySelector('script')).toBeNull()
expect(container.querySelector('svg')).not.toHaveAttribute('onload')
})
@@ -1,9 +1,9 @@
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { Maximize2 } from 'lucide-react'
import type mermaidNamespace from 'mermaid'
import DOMPurify from 'dompurify'
import { readableMermaidFlowchartSvg } from '../../../../shared/mermaid-flowchart-contrast'
import { getMermaidConfig } from './mermaid-config'
import { sanitizeMermaidSvg } from './mermaid-sanitize'
import { readMermaidThemeColors } from './mermaid-theme-colors'
import { translate } from '@/i18n/i18n'
@@ -24,7 +24,6 @@ export type RenderedMermaidDiagram = { svg: string; background?: string }
type MermaidBlockProps = {
content: string
isDark: boolean
htmlLabels?: boolean
className?: string
pendingContent?: React.ReactNode
renderEnabled?: boolean
@@ -49,7 +48,6 @@ function enqueueRender(fn: () => Promise<void>): void {
export default function MermaidBlock({
content,
isDark,
htmlLabels = false,
className,
pendingContent,
renderEnabled = true,
@@ -81,11 +79,11 @@ export default function MermaidBlock({
}
// Initialize inside the serialized task because global Mermaid config can otherwise change between initialization and render.
const colors = readMermaidThemeColors()
mermaid.initialize(getMermaidConfig(isDark, htmlLabels, isDark ? colors : undefined))
mermaid.initialize(getMermaidConfig(isDark, isDark ? colors : undefined))
const { svg } = await mermaid.render(`mermaid-${id}`, content)
if (!cancelled) {
// Sanitize explicitly against XSS even if Mermaid’s internal sanitization changes.
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })
const sanitized = sanitizeMermaidSvg(svg)
setRendered({
content,
result: {
@@ -121,7 +119,7 @@ export default function MermaidBlock({
return () => {
cancelled = true
}
}, [content, htmlLabels, isDark, id, renderEnabled])
}, [content, isDark, id, renderEnabled])
useLayoutEffect(() => {
if (renderEnabled && rendered?.content === content) {
@@ -105,11 +105,9 @@ export default function MermaidViewer({
return (
<div ref={rootRef} className="mermaid-viewer h-full min-h-0 overflow-auto scrollbar-editor">
<div className="mermaid-viewer-canvas">
{/* DOMPurify strips foreignObject HTML labels; request native SVG text so labels survive sanitization. */}
<MermaidBlock
content={diagramSource}
isDark={isDark}
htmlLabels={false}
onRenderComplete={onRenderComplete}
onExpand={expandDiagram}
/>
@@ -138,13 +138,12 @@ export function RichMarkdownCodeBlock({
)}
</button>
<NodeViewContent<'pre'> as="pre" />
{/* Keep Mermaid source editable; native SVG labels survive the preview’s sanitization. */}
{/* Keep Mermaid source editable; the diagram below is read-only output. */}
{isMermaid && node.textContent.trim() && (
<div contentEditable={false} className="mermaid-preview">
<MermaidBlock
content={node.textContent.trim()}
isDark={isDark}
htmlLabels={false}
onExpand={expandMermaidDiagram}
/>
</div>
@@ -31,7 +31,7 @@ function luminance(color: string | boolean | undefined): number {
function resolvedDarkTheme(): NonNullable<
ReturnType<typeof mermaid.mermaidAPI.getConfig>['themeVariables']
> {
mermaid.initialize(getMermaidConfig(true, false, darkColors))
mermaid.initialize(getMermaidConfig(true, darkColors))
const variables = mermaid.mermaidAPI.getConfig().themeVariables
if (!variables) {
throw new Error('Mermaid did not resolve the theme')
@@ -11,7 +11,6 @@ export type MermaidThemeColors = {
export function getMermaidConfig(
isDark: boolean,
htmlLabels = false,
colors?: MermaidThemeColors
): Parameters<typeof mermaid.initialize>[0] {
return {
@@ -47,6 +46,7 @@ export function getMermaidConfig(
}
}
: {}),
htmlLabels
// Strict mode sanitizes label HTML; sanitizeMermaidSvg keeps the foreignObject labels it emits.
htmlLabels: true
}
}
@@ -0,0 +1,136 @@
// Why: happy-dom does not exercise DOMPurify's Chromium namespace rules for
// foreignObject XHTML; this suite is the security regression gate for #12414.
// Unit CI does not install Playwright browsers — probe once and skip honestly
// (not a silent pass) when Chromium is unavailable.
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
import { chromium, type Browser } from '@playwright/test'
import { afterAll, describe, expect, it } from 'vitest'
import { mermaidSvgSanitizeConfig } from './mermaid-sanitize'
declare global {
var DOMPurify: { sanitize: (dirty: string, config: unknown) => string } | undefined
}
const require = createRequire(import.meta.url)
// One browser for the suite keeps each case well inside the default test timeout.
const browser: Browser | null = await chromium.launch({ headless: true }).catch(() => null)
const chromiumAvailable = browser !== null
afterAll(async () => {
await browser?.close()
})
async function sanitizeInChromium(svg: string): Promise<string> {
const purifyPath = require.resolve('dompurify/dist/purify.min.js')
const purifySrc = readFileSync(purifyPath, 'utf8')
if (!browser) {
throw new Error('Chromium is unavailable')
}
const page = await browser.newPage()
try {
await page.route('https://orca-mermaid.invalid/**', (route) => route.abort())
await page.setContent('<!doctype html><html><body></body></html>')
await page.addScriptTag({ content: purifySrc })
return await page.evaluate(
({ svg, cfg }) => {
if (!globalThis.DOMPurify) {
throw new Error('DOMPurify not loaded')
}
const clean = globalThis.DOMPurify.sanitize(svg, cfg)
const root = document.createElement('div')
document.body.append(root)
root.innerHTML = clean
for (const element of root.querySelectorAll('*')) {
for (const type of ['click', 'error', 'load', 'focus']) {
element.dispatchEvent(new Event(type))
}
}
if (document.body.dataset.mermaidXss) {
throw new Error('A sanitized label executed an event handler')
}
return root.innerHTML
},
{ svg, cfg: mermaidSvgSanitizeConfig }
)
} finally {
await page.close()
}
}
describe.skipIf(!chromiumAvailable)('sanitizeMermaidSvg (Chromium)', () => {
it('keeps mermaid HTML label formatting tags inside foreignObject', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><g><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml"><b>Bold</b> and <i>italic</i></div></foreignObject></g></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('foreignObject')
expect(out).toMatch(/<b[\s>]/i)
expect(out).toMatch(/<i[\s>]/i)
expect(out).toContain('Bold')
})
it('strips script, event handlers, and javascript: URLs while keeping label text', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><g><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml"><b onclick="alert(1)">Bold</b><script>alert(1)</script><a href="javascript:alert(1)">x</a><img src="x" onerror="alert(1)"></div></foreignObject><script>alert(2)</script></g></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('Bold')
expect(out).toMatch(/<b[\s>]/i)
expect(out.toLowerCase()).not.toContain('<script')
expect(out.toLowerCase()).not.toContain('onclick')
expect(out.toLowerCase()).not.toContain('onerror')
expect(out.toLowerCase()).not.toContain('javascript:')
})
it('removes nested embedded documents from HTML labels', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml"><b>Safe</b><iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe><object data="javascript:alert(1)"><embed src="javascript:alert(1)" /></object></div></foreignObject></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('<b>Safe</b>')
expect(out).not.toMatch(/<(?:iframe|object|embed)\b|srcdoc|javascript:/i)
})
it('removes interactive form controls while preserving formatting and images', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><image href="https://orca-mermaid.invalid/svg-image.png" width="20" height="20" /><foreignObject width="300" height="120"><div xmlns="http://www.w3.org/1999/xhtml"><form action="https://orca-mermaid.invalid/submit"><input name="probe" value="marker" /><button type="submit">Submit label</button><select><optgroup label="Choices"><option>Choice</option></optgroup></select><textarea>Entry</textarea><datalist><option>Suggested</option></datalist><fieldset><legend>Group</legend><label>Label</label></fieldset><output>Result</output></form><b>Safe</b><img src="https://orca-mermaid.invalid/image.png" alt="Image label" /><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>x</mi></math></div></foreignObject></svg>`
const out = await sanitizeInChromium(svg)
expect(out).not.toMatch(
/<(?:form|input|button|select|option|optgroup|textarea|datalist|fieldset|legend|label|output)\b/i
)
expect(out).toContain('<b>Safe</b>')
expect(out).toContain('<image')
expect(out).toContain('<img')
expect(out).toContain('Image label')
expect(out).toContain('<math')
})
it('removes encoded script URLs and event attributes', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml"><b oNcLiCk="&#x64;ocument.body.dataset.mermaidXss='click'">Safe</b><a href="&#x6a;ava&#x73;cript:alert(1)">first</a><a href="java&#x09;script:alert(1)">second</a><a href="javascript&colon;alert(1)">third</a><img src="invalid:" oNeRrOr="&#100;ocument.body.dataset.mermaidXss='error'" /></div></foreignObject></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('<b>Safe</b>')
expect(out).toContain('first')
expect(out).not.toMatch(/\bonclick\s*=|\bonerror\s*=|\bhref\s*=/i)
})
it('keeps encoded markup as label text through browser reparsing', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml">&lt;img src=x onerror="document.body.dataset.mermaidXss='encoded'"&gt;</div></foreignObject></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('&lt;img')
expect(out).not.toContain('<img')
})
it('blocks handlers across nested MathML and HTML namespace transitions', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml"><b>Safe</b><math xmlns="http://www.w3.org/1998/Math/MathML"><mtext><svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><img src="invalid:" onerror="document.body.dataset.mermaidXss='namespace'" /><iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe></div></foreignObject></svg></mtext><annotation-xml encoding="text/html"><div xmlns="http://www.w3.org/1999/xhtml" onclick="document.body.dataset.mermaidXss='annotation'">nested</div></annotation-xml></math></div></foreignObject></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('<b>Safe</b>')
expect(out).not.toMatch(/\bonerror\s*=|\bonclick\s*=|<iframe\b|srcdoc/i)
})
it('keeps generated styles and math labels without enabling SVG filter primitives', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><style>.label { font-weight: 700; }</style><defs><marker id="arrow"><path d="M0 0L5 5" /></marker><filter id="blur"><feGaussianBlur stdDeviation="3" /><feImage href="javascript:alert(1)" /></filter></defs><foreignObject width="100" height="40"><div xmlns="http://www.w3.org/1999/xhtml"><b>Safe</b><math xmlns="http://www.w3.org/1998/Math/MathML"><msup><mi>x</mi><mn>2</mn></msup></math></div></foreignObject></svg>`
const out = await sanitizeInChromium(svg)
expect(out).toContain('font-weight: 700')
expect(out).toContain('<marker')
expect(out).toContain('<b>Safe</b>')
expect(out).toContain('<math')
expect(out).toContain('<msup>')
expect(out).not.toMatch(/<feGaussianBlur\b|<feImage\b/i)
})
})
@@ -0,0 +1,28 @@
import type { Config } from 'dompurify'
import DOMPurify from 'dompurify'
// XHTML integration keeps labels visible; math labels need MathML, while SVG filters stay excluded.
export const mermaidSvgSanitizeConfig: Config = {
USE_PROFILES: { html: true, svg: true, mathMl: true },
FORBID_TAGS: [
'form',
'input',
'button',
'select',
'textarea',
'datalist',
'option',
'optgroup',
'fieldset',
'legend',
'label',
'output'
],
ADD_TAGS: ['foreignobject'],
ADD_ATTR: ['dominant-baseline'],
HTML_INTEGRATION_POINTS: { foreignobject: true }
}
export function sanitizeMermaidSvg(svg: string): string {
return DOMPurify.sanitize(svg, mermaidSvgSanitizeConfig)
}
@@ -179,7 +179,6 @@ export function useMarkdownPreviewComponents({
<MermaidBlock
content={String(children).trimEnd()}
isDark={isDark}
htmlLabels={false}
onExpand={expandMermaidDiagram}
/>
)
@@ -4,7 +4,7 @@ import { useMermaidDiagramExpand } from '@/components/editor/use-mermaid-diagram
import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme'
import { cn } from '@/lib/utils'
// Resolve live document theme here; disable HTML labels because sanitized SVG foreignObjects disappear on some platforms.
// Resolve live document theme here; comment markdown components have no theme access.
export default function CommentMermaidBlock({
content,
className,
@@ -22,7 +22,7 @@ export default function CommentMermaidBlock({
if (pendingContent === undefined) {
return (
<div className={cn(className)}>
<MermaidBlock content={content} isDark={isDark} htmlLabels={false} onExpand={expand} />
<MermaidBlock content={content} isDark={isDark} onExpand={expand} />
</div>
)
}
@@ -31,7 +31,6 @@ export default function CommentMermaidBlock({
<MermaidBlock
content={content}
isDark={isDark}
htmlLabels={false}
className={className}
pendingContent={pendingContent}
renderEnabled={renderEnabled}