fix: more mermaid protection

This commit is contained in:
Alex committed 2026-08-12 15:01:41 +01:00
1 parent 4b1bc17c77
commit 8dd4c9a417
6 files changed
+266 -28

No files matched your search

@@ -1,4 +1,3 @@
import mermaid from 'mermaid';
import { Download, FileText, History, RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useSelector } from 'react-redux';
@@ -7,6 +6,7 @@ import userService from '../api/services/userService';
import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import MarkdownPreview from './MarkdownPreview';
import { renderMermaidDiagram } from './mermaidSecurity';
import Spinner from './Spinner';
import {
buildPreviewDocument,
@@ -67,13 +67,12 @@ function FramePreview({
let cancelled = false;
setRendered(null);
setRenderError(false);
mermaid.initialize({
startOnLoad: false,
renderMermaidDiagram({
id: `artifact-mermaid-${Date.now()}`,
code: source,
isDarkTheme,
securityLevel: 'sandbox',
theme: isDarkTheme ? 'dark' : 'default',
});
mermaid
.render(`artifact-mermaid-${Date.now()}`, source)
})
.then(({ svg }) => {
if (!cancelled) setRendered(svg);
})
@@ -0,0 +1,54 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const renderMermaidDiagramMock = vi.hoisted(() => vi.fn());
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({ useSelector: () => 'idle' }));
vi.mock('../hooks', () => ({ useDarkTheme: () => [false] }));
vi.mock('./CopyButton', () => ({ default: () => null }));
vi.mock('./mermaidSecurity', () => ({
renderMermaidDiagram: renderMermaidDiagramMock,
}));
import MermaidRenderer from './MermaidRenderer';
describe('MermaidRenderer', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderMermaidDiagramMock.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('keeps its visible host when Mermaid removes its temporary render ID', async () => {
renderMermaidDiagramMock.mockImplementation(
async ({ id }: { id: string }) => {
// Mermaid removes an existing node with the supplied render ID.
document.getElementById(id)?.remove();
return { svg: '<svg data-rendered="true"></svg>' };
},
);
await act(async () => {
root.render(
<MermaidRenderer code={'flowchart LR\nA --> B'} isLoading={false} />,
);
});
expect(container.querySelector('pre.mermaid')).not.toBeNull();
expect(container.querySelector('svg[data-rendered="true"]')).not.toBeNull();
});
});
+30 -21
View File
@@ -1,4 +1,3 @@
import mermaid from 'mermaid';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -11,6 +10,7 @@ import {
import { selectStatus } from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import CopyButton from './CopyButton';
import { renderMermaidDiagram } from './mermaidSecurity';
import { Button } from './ui/button';
import { MermaidRendererProps } from './types';
@@ -23,12 +23,14 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
const diagramId = useRef(
`mermaid-${Date.now()}-${Math.random().toString(36).substring(2)}`,
);
const renderSequence = useRef(0);
const status = useSelector(selectStatus);
const [error, setError] = useState<string | null>(null);
const [showCode, setShowCode] = useState<boolean>(false);
const [showDownloadMenu, setShowDownloadMenu] = useState<boolean>(false);
const downloadMenuRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLPreElement>(null);
const [hoverPosition, setHoverPosition] = useState<{
x: number;
y: number;
@@ -83,26 +85,33 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
return `${hoverPosition.x * 100}% ${hoverPosition.y * 100}%`;
};
useEffect(() => {
const renderDiagram = async () => {
mermaid.initialize({
startOnLoad: true,
theme: isDarkTheme ? 'dark' : 'default',
securityLevel: 'loose',
suppressErrorRendering: true,
});
const isCurrentlyLoading =
isLoading !== undefined ? isLoading : status === 'loading';
const isCurrentlyLoading =
isLoading !== undefined ? isLoading : status === 'loading';
useEffect(() => {
let cancelled = false;
const renderDiagram = async () => {
if (!isCurrentlyLoading && code) {
try {
const element = document.getElementById(diagramId.current);
setError(null);
const { svg, bindFunctions } = await renderMermaidDiagram({
// Mermaid owns and removes this temporary ID while rendering. It
// must never match the visible host's ID.
id: `${diagramId.current}-render-${++renderSequence.current}`,
code,
isDarkTheme,
securityLevel: 'strict',
});
if (cancelled) return;
const element = diagramRef.current;
if (element) {
element.removeAttribute('data-processed');
await mermaid.parse(code); //syntax check
mermaid.contentLoaded();
// Mermaid strict mode sanitizes this generated SVG before it is returned.
element.innerHTML = svg;
bindFunctions?.(element);
}
} catch (err) {
if (cancelled) return;
console.error('Error rendering mermaid diagram:', err);
setError(
`Failed to render diagram: ${err instanceof Error ? err.message : String(err)}`,
@@ -112,7 +121,10 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
};
renderDiagram();
}, [code, isDarkTheme, isLoading]);
return () => {
cancelled = true;
};
}, [code, isDarkTheme, isCurrentlyLoading]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -258,8 +270,6 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
{ label: 'Download as MMD', action: downloadMmd },
];
const isCurrentlyLoading =
isLoading !== undefined ? isLoading : status === 'loading';
const showDiagramOptions = !isCurrentlyLoading && !error;
const errorRender = !isCurrentlyLoading && error;
@@ -395,6 +405,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
</>
)}
<pre
ref={diagramRef}
className="mermaid w-full select-none"
id={diagramId.current}
key={`mermaid-${diagramId.current}`}
@@ -407,9 +418,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
display: 'flex',
justifyContent: 'center',
}}
>
{code}
</pre>
/>
</div>
{showCode && (
@@ -0,0 +1,95 @@
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}));
vi.mock('mermaid', () => ({ default: mermaidMock }));
import { renderMermaidDiagram } from './mermaidSecurity';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
describe('renderMermaidDiagram', () => {
beforeEach(() => {
mermaidMock.initialize.mockReset();
mermaidMock.render.mockReset();
});
it('serializes initialization with rendering across security levels', async () => {
const firstRender = deferred<{ svg: string }>();
mermaidMock.render
.mockImplementationOnce(() => firstRender.promise)
.mockResolvedValueOnce({ svg: '<iframe sandbox=""></iframe>' });
const conversation = renderMermaidDiagram({
id: 'conversation-diagram',
code: 'flowchart LR\nA --> B',
isDarkTheme: false,
securityLevel: 'strict',
});
const artifact = renderMermaidDiagram({
id: 'artifact-diagram',
code: 'flowchart LR\nB --> C',
isDarkTheme: true,
securityLevel: 'sandbox',
});
await vi.waitFor(() => expect(mermaidMock.render).toHaveBeenCalledTimes(1));
expect(mermaidMock.initialize).toHaveBeenLastCalledWith(
expect.objectContaining({ securityLevel: 'strict' }),
);
firstRender.resolve({ svg: '<svg></svg>' });
await conversation;
await artifact;
expect(mermaidMock.initialize.mock.calls.map(([config]) => config)).toEqual(
[
expect.objectContaining({ securityLevel: 'strict' }),
expect.objectContaining({ securityLevel: 'sandbox' }),
],
);
expect(mermaidMock.render.mock.calls).toEqual([
['conversation-diagram', 'flowchart LR\nA --> B'],
['artifact-diagram', 'flowchart LR\nB --> C'],
]);
});
it('renders from the code argument again when only the theme changes', async () => {
mermaidMock.render.mockResolvedValue({ svg: '<svg></svg>' });
const code = 'flowchart LR\nA --> B';
await renderMermaidDiagram({
id: 'theme-diagram',
code,
isDarkTheme: false,
securityLevel: 'strict',
});
await renderMermaidDiagram({
id: 'theme-diagram',
code,
isDarkTheme: true,
securityLevel: 'strict',
});
expect(mermaidMock.render).toHaveBeenNthCalledWith(
1,
'theme-diagram',
code,
);
expect(mermaidMock.render).toHaveBeenNthCalledWith(
2,
'theme-diagram',
code,
);
expect(
mermaidMock.initialize.mock.calls.map(([config]) => config.theme),
).toEqual(['default', 'dark']);
});
});
@@ -0,0 +1,35 @@
import mermaid from 'mermaid';
import { initializeSecureMermaid } from './mermaidSecurity';
describe('initializeSecureMermaid', () => {
beforeEach(() => {
document.body.replaceChildren();
});
it('ignores security-level downgrade attempts in init directives', async () => {
initializeSecureMermaid(false);
const storedPayload = [
'%%{init: {"securityLevel":"loose"}}%%',
'flowchart LR',
' A[Click me]',
' click A "javascript:alert(document.domain)"',
].join('\n');
const parsed = await mermaid.parse(storedPayload);
expect(parsed).toMatchObject({ diagramType: 'flowchart-v2' });
expect(mermaid.mermaidAPI.getConfig().securityLevel).toBe('strict');
});
it('continues to accept ordinary diagrams', async () => {
initializeSecureMermaid(true);
const parsed = await mermaid.parse(
'flowchart LR\n A[Start] --> B[Finish]',
);
expect(parsed).toMatchObject({ diagramType: 'flowchart-v2' });
expect(mermaid.mermaidAPI.getConfig().theme).toBe('dark');
});
});
@@ -0,0 +1,46 @@
import mermaid, { type RenderResult } from 'mermaid';
export type MermaidSecurityLevel = 'strict' | 'sandbox';
type MermaidRenderOptions = {
id: string;
code: string;
isDarkTheme: boolean;
securityLevel: MermaidSecurityLevel;
};
// Mermaid's own render queue does not snapshot its process-global config.
// Keep initialization and its matching render in one application-level task.
let renderQueue: Promise<void> = Promise.resolve();
/** Configure Mermaid immediately before one queued render. */
export function initializeSecureMermaid(
isDarkTheme: boolean,
securityLevel: MermaidSecurityLevel = 'strict',
): void {
mermaid.initialize({
startOnLoad: false,
theme: isDarkTheme ? 'dark' : 'default',
securityLevel,
suppressErrorRendering: true,
});
}
/** Render untrusted Mermaid source with configuration isolated from other views. */
export function renderMermaidDiagram({
id,
code,
isDarkTheme,
securityLevel,
}: MermaidRenderOptions): Promise<RenderResult> {
const render = () => {
initializeSecureMermaid(isDarkTheme, securityLevel);
return mermaid.render(id, code);
};
const result = renderQueue.then(render, render);
renderQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}