mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 02:15:06 +00:00
fix: more mermaid protection
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user