= ({
>
)}
= ({
display: 'flex',
justifyContent: 'center',
}}
- >
- {code}
-
+ />
{showCode && (
diff --git a/frontend/src/components/mermaidRenderQueue.test.ts b/frontend/src/components/mermaidRenderQueue.test.ts
new file mode 100644
index 00000000..80b40fe6
--- /dev/null
+++ b/frontend/src/components/mermaidRenderQueue.test.ts
@@ -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() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((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: '' });
+
+ 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: '' });
+ 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: '' });
+ 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']);
+ });
+});
diff --git a/frontend/src/components/mermaidSecurity.test.ts b/frontend/src/components/mermaidSecurity.test.ts
new file mode 100644
index 00000000..312d4a71
--- /dev/null
+++ b/frontend/src/components/mermaidSecurity.test.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/components/mermaidSecurity.ts b/frontend/src/components/mermaidSecurity.ts
new file mode 100644
index 00000000..57f098b2
--- /dev/null
+++ b/frontend/src/components/mermaidSecurity.ts
@@ -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 = 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 {
+ const render = () => {
+ initializeSecureMermaid(isDarkTheme, securityLevel);
+ return mermaid.render(id, code);
+ };
+ const result = renderQueue.then(render, render);
+ renderQueue = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ return result;
+}