fix(web-shell): remove redundant sanitizeSvg, fix mermaid render failure (#5123)

Mermaid diagrams with cylinder shapes [(...)] and CJK/emoji content
failed to render because DOMParser strict XML parsing rejected
XHTML content (e.g. <br>) inside <foreignObject>, causing
sanitizeSvg to return empty string and display 'Mermaid render failed'.

The sanitizeSvg function was redundant — mermaid's securityLevel:'strict'
already uses DOMPurify internally for XSS protection. Removing the
custom sanitizer fixes the rendering issue and simplifies the code.

Co-authored-by: ytahdn <ytahdn@gmail.com>
This commit is contained in:
ytahdn 2026-06-15 15:48:54 +08:00 committed by GitHub
parent 6677ca1dd3
commit 136754bb1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 5 additions and 236 deletions

View file

@ -4,7 +4,7 @@
import { act, createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { describe, expect, it } from 'vitest';
import { isSafeHref, isSafeImageSrc, Markdown, sanitizeSvg } from './Markdown';
import { isSafeHref, isSafeImageSrc, Markdown } from './Markdown';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@ -124,154 +124,3 @@ describe('Markdown mermaid rendering', () => {
container.remove();
});
});
describe('sanitizeSvg', () => {
it('strips script elements', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect/></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('<script');
expect(result).toContain('<rect');
});
it('keeps foreignObject elements (mermaid uses them for text labels)', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div>Label</div></foreignObject></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('foreignObject');
expect(result).toContain('Label');
});
it('strips on* handlers inside foreignObject', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div onclick="alert(1)">Label</div></foreignObject></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('foreignObject');
expect(result).not.toContain('onclick');
});
it('keeps style elements with safe CSS (mermaid theming)', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><style>.node{fill:#fff}</style><rect/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('<style');
expect(result).toContain('.node{fill:#fff}');
});
it('strips @import from style elements', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><style>@import url("https://evil.com/track.css"); .node{fill:#fff}</style><rect/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('<style');
expect(result).not.toContain('@import');
expect(result).toContain('.node{fill:#fff}');
});
it('strips external url() from style elements but keeps local url(#id)', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><style>.bg{fill:url(https://evil.com/x)} .fg{fill:url(#grad)}</style><rect/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('url(#grad)');
expect(result).not.toContain('url(https://');
});
it('strips image elements (external resource loading)', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.com/track"/></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('<image');
});
it('strips feImage elements', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><filter><feImage href="https://evil.com"/></filter></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('feImage');
});
it('keeps use elements with fragment-only href', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><defs><marker id="m"/></defs><use href="#m"/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('<use');
});
it('keeps use elements with xlink:href fragment reference', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><marker id="arrow"/></defs><use xlink:href="#arrow"/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('<use');
});
it('strips use elements with external href', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><use href="https://evil.com/sprite.svg#icon"/></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('<use');
});
it('strips use elements with no href', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><use/></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('<use');
});
it('removes on* event handler attributes', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect onclick="alert(1)" onload="alert(2)"/></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('onclick');
expect(result).not.toContain('onload');
});
it('removes href attributes with javascript: scheme', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"><text>click</text></a></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('javascript:');
});
it('removes href attributes with external URLs', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><a href="https://evil.com"><text>link</text></a></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('https://evil.com');
});
it('removes style attributes with external url()', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(https://evil.com/track)"/></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('url(https://');
});
it('keeps style attributes with fragment-only url()', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect style="fill:url(#gradient0)"/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('url(#gradient0)');
});
it('strips animate elements', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect><animate attributeName="x" values="0;100"/></rect></svg>';
const result = sanitizeSvg(svg);
expect(result).not.toContain('<animate');
});
it('returns empty string for invalid SVG', () => {
expect(sanitizeSvg('<not-valid-svg>')).toBe('');
});
it('returns empty string for empty input', () => {
expect(sanitizeSvg('')).toBe('');
});
it('passes through safe SVG content', () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
const result = sanitizeSvg(svg);
expect(result).toContain('<rect');
expect(result).toContain('fill="red"');
});
});

View file

@ -67,83 +67,6 @@ const SUPPORTED_LANGUAGES = new Set([
'diff',
]);
// Sanitize mermaid SVG output to prevent XSS while preserving rendering.
//
// Why <style> is kept (not removed):
// Mermaid embeds <style> in SVG for theming (colors, fonts, backgrounds).
// Removing it causes diagrams to render as unstyled black shapes.
// Instead we strip dangerous CSS constructs (@import, external url()).
//
// Why <foreignObject> is kept (not removed):
// Mermaid uses <foreignObject> for text labels in flowcharts, sequence
// diagrams, etc. Removing it makes all text disappear.
// With securityLevel:'strict', mermaid already escapes user input inside
// foreignObject. Our attribute sanitizer below still strips on* handlers
// and dangerous href/src values from all child elements.
export function sanitizeSvg(svg: string): string {
if (typeof DOMParser === 'undefined') return '';
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
if (doc.querySelector('parsererror')) return '';
doc
.querySelectorAll(
'script, iframe, object, embed, link, ' +
'animate, set, animateTransform, animateMotion, ' +
'image, feImage, mpath',
)
.forEach((node) => node.remove());
// Keep <style> but strip dangerous CSS: @import (external resource loading)
// and external url() references (data exfiltration). Local url(#id) is safe.
doc.querySelectorAll('style').forEach((node) => {
const css = node.textContent || '';
node.textContent = css
.replace(/@import\b[^;]*/gi, '')
.replace(/url\(\s*(?!['"]?#)[^)]*\)/gi, 'url()');
});
doc.querySelectorAll('use').forEach((node) => {
const hrefs = [
node.getAttribute('href'),
node.getAttribute('xlink:href'),
node.getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
].filter((h): h is string => h !== null);
if (hrefs.length === 0 || hrefs.some((h) => !h.startsWith('#'))) {
node.remove();
}
});
for (const element of Array.from(doc.querySelectorAll('*'))) {
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith('on')) {
element.removeAttribute(attr.name);
continue;
}
if (name === 'href' || name.endsWith(':href') || name === 'src') {
if (
value.startsWith('javascript:') ||
value.startsWith('data:') ||
value.startsWith('http:') ||
value.startsWith('https:') ||
value.startsWith('//')
) {
element.removeAttribute(attr.name);
}
}
if (/url\(/i.test(attr.value)) {
const hasExternalUrl = /url\(\s*(?!['"]?#)/i.test(attr.value);
if (hasExternalUrl) {
element.removeAttribute(attr.name);
}
}
}
}
return doc.documentElement.outerHTML;
}
const SAFE_HREF_SCHEMES = /^(https?:|mailto:)/i;
const SAFE_IMAGE_DATA_URI = /^data:image\/(png|jpeg|gif|webp);base64,/i;
@ -224,14 +147,11 @@ function MermaidBlock({ code }: { code: string }) {
}
try {
const id = `mermaid-${++mermaidRenderId}`;
const { svg: rendered } = await mermaid.render(id, code.trim());
const safeSvg = sanitizeSvg(rendered);
const { svg } = await mermaid.render(id, code.trim());
// No additional sanitization needed: securityLevel:'strict' uses
// DOMPurify internally to sanitize SVG output.
if (!cancelled) {
if (safeSvg) {
setSvg(safeSvg);
} else {
setError('Mermaid render failed');
}
setSvg(svg);
}
} catch (error: unknown) {
if (!cancelled) {