fix: mermaid graph (#29811)

Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
This commit is contained in:
-LAN-
2025-12-18 10:50:14 +08:00
committed by GitHub
parent 114f17f1ca
commit ae17537470
3 changed files with 86 additions and 11 deletions

View File

@@ -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<MermaidConfig['flowchart']> & { 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 && (
<div className='px-[26px] py-4'>
<LoadingAnim type='text'/>
<LoadingAnim type='text' />
<div className="mt-2 text-sm text-gray-500">
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
</div>
@@ -564,7 +569,7 @@ const Flowchart = (props: FlowchartProps) => {
{errMsg && (
<div className={themeClasses.errorMessage}>
<div className="flex items-center">
<ExclamationTriangleIcon className={themeClasses.errorIcon}/>
<ExclamationTriangleIcon className={themeClasses.errorIcon} />
<span className="ml-2">{errMsg}</span>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { cleanUpSvgCode } from './utils'
import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils'
describe('cleanUpSvgCode', () => {
it('replaces old-style <br> tags with the new style', () => {
@@ -6,3 +6,54 @@ describe('cleanUpSvgCode', () => {
expect(result).toEqual('<br/>test<br/>')
})
})
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)
})
})

View File

@@ -2,6 +2,28 @@ export function cleanUpSvgCode(svgCode: string): string {
return svgCode.replaceAll('<br>', '<br/>')
}
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(/<br\s*\/?>/g, '\n')