Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 2 additions & 16 deletions client/src/hooks/Mermaid/useMermaid.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useContext, useMemo, useState } from 'react';
import useSWR from 'swr';
import { Md5 } from 'ts-md5';
import DOMPurify from 'dompurify';
import { ThemeContext, isDark } from '@librechat/client';
import type { MermaidConfig } from 'mermaid';
import { inlineFlowchartConfig } from '~/utils/mermaid';
import { inlineFlowchartConfig, sanitizeMermaidSvg } from '~/utils/mermaid';

// Constants
const MD5_LENGTH_THRESHOLD = 10_000;
Expand Down Expand Up @@ -131,20 +130,7 @@ export const useMermaid = ({
// Render to SVG
const { svg } = await mermaidInstance.render(diagramId, content);

// Sanitize SVG output with DOMPurify for additional security
const purify = DOMPurify();
const sanitizedSvg = purify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
// Allow additional elements used by mermaid for text rendering
ADD_TAGS: ['foreignObject', 'use', 'switch'],
ADD_ATTR: [
'dominant-baseline',
'text-anchor',
'requiredFeatures',
'systemLanguage',
'xmlns:xlink',
],
});
const sanitizedSvg = sanitizeMermaidSvg(svg);

// Store as last valid content
setValidContent(sanitizedSvg);
Expand Down
84 changes: 84 additions & 0 deletions client/src/utils/__tests__/mermaid.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
fixSubgraphTitleContrast,
sanitizeMermaidSvg,
artifactFlowchartConfig,
inlineFlowchartConfig,
getMermaidFiles,
Expand Down Expand Up @@ -169,4 +170,87 @@ describe('mermaid config', () => {
expect(style).toContain('fill: #1a1a1a');
});
});

describe('sanitizeMermaidSvg', () => {
const wrap = (inner: string) => `<svg xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;

it('produces valid XML parseable as image/svg+xml', () => {
const svg = wrap('<rect width="10" height="10"/>');
const result = sanitizeMermaidSvg(svg);
const doc = new DOMParser().parseFromString(result, 'image/svg+xml');
expect(doc.querySelector('parsererror')).toBeNull();
});

it('preserves foreignObject and its HTML children', () => {
const svg = wrap(
'<foreignObject width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml"><p>Hello</p></div></foreignObject>',
);
const result = sanitizeMermaidSvg(svg);
expect(result).toContain('<foreignObject');
expect(result).toContain('<p>Hello</p>');
});

it('serializes <br> as self-closing <br /> for XML compatibility', () => {
const svg = wrap(
'<foreignObject width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml"><p>Line1<br/>Line2</p></div></foreignObject>',
);
const result = sanitizeMermaidSvg(svg);
expect(result).toMatch(/<br\s*\/>/);
expect(result).not.toMatch(/<br\s*>/);
});

it('retains xmlns="http://www.w3.org/2000/svg" on the root svg element', () => {
const svg = wrap('<rect width="10" height="10"/>');
const result = sanitizeMermaidSvg(svg);
expect(result).toContain('xmlns="http://www.w3.org/2000/svg"');
});

it('strips script tags from SVG', () => {
const svg = wrap('<script>alert("xss")</script><rect width="10" height="10"/>');
const result = sanitizeMermaidSvg(svg);
expect(result).not.toContain('<script');
expect(result).not.toContain('alert');
});

it('strips event handler attributes', () => {
const svg = wrap('<rect width="10" height="10" onclick="alert(1)"/>');
const result = sanitizeMermaidSvg(svg);
expect(result).not.toContain('onclick');
});

it('strips script tags inside foreignObject HTML', () => {
const svg = wrap(
'<foreignObject width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert("xss")</script><p>Safe</p></div></foreignObject>',
);
const result = sanitizeMermaidSvg(svg);
expect(result).not.toContain('<script');
expect(result).toContain('Safe');
});

it('strips javascript: URLs in foreignObject HTML', () => {
const svg = wrap(
'<foreignObject width="100" height="50"><div xmlns="http://www.w3.org/1999/xhtml"><a href="javascript:alert(1)">click</a></div></foreignObject>',
);
const result = sanitizeMermaidSvg(svg);
expect(result).not.toContain('javascript:');
});

it('preserves mermaid-specific attributes on SVG elements', () => {
const svg = wrap('<text dominant-baseline="middle" text-anchor="start">label</text>');
const result = sanitizeMermaidSvg(svg);
expect(result).toContain('dominant-baseline');
expect(result).toContain('text-anchor');
});

it('preserves styled span and class names inside foreignObject', () => {
const svg = wrap(
'<foreignObject width="150" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell;"><span class="nodeLabel"><p>Node text<br/>second line</p></span></div></foreignObject>',
);
const result = sanitizeMermaidSvg(svg);
expect(result).toContain('nodeLabel');
expect(result).toContain('Node text');
expect(result).toContain('second line');
expect(result).toMatch(/<br\s*\/>/);
});
});
});
19 changes: 19 additions & 0 deletions client/src/utils/mermaid.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dedent from 'dedent';
import DOMPurify from 'dompurify';
Comment thread
ethanlaj marked this conversation as resolved.

interface MermaidButtonStyles {
bg: string;
Expand Down Expand Up @@ -484,3 +485,21 @@ root.render(<App />);
'mermaid.css': mermaidCSS,
};
};

/** Sanitize mermaid SVG and serialize as XML, preserving foreignObject HTML (cure53/DOMPurify#1002). */
export const sanitizeMermaidSvg = (svg: string): string =>
new XMLSerializer().serializeToString(
DOMPurify().sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject', 'use', 'switch'],
ADD_ATTR: [
'dominant-baseline',
'text-anchor',
'requiredFeatures',
'systemLanguage',
'xmlns:xlink',
],
HTML_INTEGRATION_POINTS: { foreignobject: true },
RETURN_DOM_FRAGMENT: true,
}),
);
Loading