From ae17537470bba417a8971fff705dd82ecb043564 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 18 Dec 2025 10:50:14 +0800 Subject: [PATCH] fix: mermaid graph (#29811) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Joel --- web/app/components/base/mermaid/index.tsx | 17 +++--- web/app/components/base/mermaid/utils.spec.ts | 53 ++++++++++++++++++- web/app/components/base/mermaid/utils.ts | 27 ++++++++-- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/web/app/components/base/mermaid/index.tsx b/web/app/components/base/mermaid/index.tsx index bf35c8c94c..92fcd5cac9 100644 --- a/web/app/components/base/mermaid/index.tsx +++ b/web/app/components/base/mermaid/index.tsx @@ -8,6 +8,7 @@ import { isMermaidCodeComplete, prepareMermaidCode, processSvgForTheme, + sanitizeMermaidCode, svgToBase64, waitForDOMElement, } from './utils' @@ -71,7 +72,7 @@ const initMermaid = () => { const config: MermaidConfig = { startOnLoad: false, fontFamily: 'sans-serif', - securityLevel: 'loose', + securityLevel: 'strict', flowchart: { htmlLabels: true, useMaxWidth: true, @@ -267,6 +268,8 @@ const Flowchart = (props: FlowchartProps) => { finalCode = prepareMermaidCode(primitiveCode, look) } + finalCode = sanitizeMermaidCode(finalCode) + // Step 2: Render chart const svgGraph = await renderMermaidChart(finalCode, look) @@ -297,9 +300,9 @@ const Flowchart = (props: FlowchartProps) => { const configureMermaid = useCallback((primitiveCode: string) => { if (typeof window !== 'undefined' && isInitialized) { const themeVars = THEMES[currentTheme] - const config: any = { + const config: MermaidConfig = { startOnLoad: false, - securityLevel: 'loose', + securityLevel: 'strict', fontFamily: 'sans-serif', maxTextSize: 50000, gantt: { @@ -325,7 +328,8 @@ const Flowchart = (props: FlowchartProps) => { config.theme = currentTheme === 'dark' ? 'dark' : 'neutral' if (isFlowchart) { - config.flowchart = { + type FlowchartConfigWithRanker = NonNullable & { ranker?: string } + const flowchartConfig: FlowchartConfigWithRanker = { htmlLabels: true, useMaxWidth: true, nodeSpacing: 60, @@ -333,6 +337,7 @@ const Flowchart = (props: FlowchartProps) => { curve: 'linear', ranker: 'tight-tree', } + config.flowchart = flowchartConfig as unknown as MermaidConfig['flowchart'] } if (currentTheme === 'dark') { @@ -531,7 +536,7 @@ const Flowchart = (props: FlowchartProps) => { {isLoading && !svgString && (
- +
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
@@ -564,7 +569,7 @@ const Flowchart = (props: FlowchartProps) => { {errMsg && (
- + {errMsg}
diff --git a/web/app/components/base/mermaid/utils.spec.ts b/web/app/components/base/mermaid/utils.spec.ts index 6ea7f17bfa..7a73aa1fc9 100644 --- a/web/app/components/base/mermaid/utils.spec.ts +++ b/web/app/components/base/mermaid/utils.spec.ts @@ -1,4 +1,4 @@ -import { cleanUpSvgCode } from './utils' +import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils' describe('cleanUpSvgCode', () => { it('replaces old-style
tags with the new style', () => { @@ -6,3 +6,54 @@ describe('cleanUpSvgCode', () => { expect(result).toEqual('
test
') }) }) + +describe('sanitizeMermaidCode', () => { + it('removes click directives to prevent link/callback injection', () => { + const unsafeProtocol = ['java', 'script:'].join('') + const input = [ + 'gantt', + 'title Demo', + 'section S1', + 'Task 1 :a1, 2020-01-01, 1d', + `click A href "${unsafeProtocol}alert(location.href)"`, + 'click B call callback()', + ].join('\n') + + const result = sanitizeMermaidCode(input) + + expect(result).toContain('gantt') + expect(result).toContain('Task 1') + expect(result).not.toContain('click A') + expect(result).not.toContain('click B') + expect(result).not.toContain(unsafeProtocol) + }) + + it('removes Mermaid init directives to prevent config overrides', () => { + const input = [ + '%%{init: {"securityLevel":"loose"}}%%', + 'graph TD', + 'A-->B', + ].join('\n') + + const result = sanitizeMermaidCode(input) + + expect(result).toEqual(['graph TD', 'A-->B'].join('\n')) + }) +}) + +describe('prepareMermaidCode', () => { + it('sanitizes click directives in flowcharts', () => { + const unsafeProtocol = ['java', 'script:'].join('') + const input = [ + 'graph TD', + 'A[Click]-->B', + `click A href "${unsafeProtocol}alert(1)"`, + ].join('\n') + + const result = prepareMermaidCode(input, 'classic') + + expect(result).toContain('graph TD') + expect(result).not.toContain('click ') + expect(result).not.toContain(unsafeProtocol) + }) +}) diff --git a/web/app/components/base/mermaid/utils.ts b/web/app/components/base/mermaid/utils.ts index 7e59869de1..e4abed3e44 100644 --- a/web/app/components/base/mermaid/utils.ts +++ b/web/app/components/base/mermaid/utils.ts @@ -2,6 +2,28 @@ export function cleanUpSvgCode(svgCode: string): string { return svgCode.replaceAll('
', '
') } +export const sanitizeMermaidCode = (mermaidCode: string): string => { + if (!mermaidCode || typeof mermaidCode !== 'string') + return '' + + return mermaidCode + .split('\n') + .filter((line) => { + const trimmed = line.trimStart() + + // Mermaid directives can override config; treat as untrusted in chat context. + if (trimmed.startsWith('%%{')) + return false + + // Mermaid click directives can create JS callbacks/links inside rendered SVG. + if (trimmed.startsWith('click ')) + return false + + return true + }) + .join('\n') +} + /** * Prepares mermaid code for rendering by sanitizing common syntax issues. * @param {string} mermaidCode - The mermaid code to prepare @@ -12,10 +34,7 @@ export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'hand if (!mermaidCode || typeof mermaidCode !== 'string') return '' - let code = mermaidCode.trim() - - // Security: Sanitize against javascript: protocol in click events (XSS vector) - code = code.replace(/(\bclick\s+\w+\s+")javascript:[^"]*(")/g, '$1#$2') + let code = sanitizeMermaidCode(mermaidCode.trim()) // Convenience: Basic BR replacement. This is a common and safe operation. code = code.replace(//g, '\n')