From 9d685178d215f76be4d5e8fe47c64dd915274738 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 5 Aug 2025 20:44:03 +0530 Subject: [PATCH 1/9] fix: Sanitize Katex --- .../mermaid/src/diagrams/common/common.ts | 26 ++++++++++------- .../mermaid/src/diagrams/sequence/svgDraw.js | 16 +++++++---- .../mermaid/src/rendering-util/createText.ts | 7 +++-- .../rendering-elements/createLabel.js | 28 +++++++++++-------- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/mermaid/src/diagrams/common/common.ts b/packages/mermaid/src/diagrams/common/common.ts index 00c9b8313..3b3fdd41e 100644 --- a/packages/mermaid/src/diagrams/common/common.ts +++ b/packages/mermaid/src/diagrams/common/common.ts @@ -311,9 +311,8 @@ export const hasKatex = (text: string): boolean => (text.match(katexRegex)?.leng * @returns Object containing \{width, height\} */ export const calculateMathMLDimensions = async (text: string, config: MermaidConfig) => { - text = await renderKatex(text, config); const divElem = document.createElement('div'); - divElem.innerHTML = text; + divElem.innerHTML = await renderKatexSanitized(text, config); divElem.id = 'katex-temp'; divElem.style.visibility = 'hidden'; divElem.style.position = 'absolute'; @@ -325,14 +324,7 @@ export const calculateMathMLDimensions = async (text: string, config: MermaidCon return dim; }; -/** - * Attempts to render and return the KaTeX portion of a string with MathML - * - * @param text - The text to test - * @param config - Configuration for Mermaid - * @returns String containing MathML if KaTeX is supported, or an error message if it is not and stylesheets aren't present - */ -export const renderKatex = async (text: string, config: MermaidConfig): Promise => { +const renderKatexUnsanitized = async (text: string, config: MermaidConfig): Promise => { if (!hasKatex(text)) { return text; } @@ -373,6 +365,20 @@ export const renderKatex = async (text: string, config: MermaidConfig): Promise< ); }; +/** + * Attempts to render and return the KaTeX portion of a string with MathML + * + * @param text - The text to test + * @param config - Configuration for Mermaid + * @returns String containing MathML if KaTeX is supported, or an error message if it is not and stylesheets aren't present + */ +export const renderKatexSanitized = async ( + text: string, + config: MermaidConfig +): Promise => { + return sanitizeText(await renderKatexUnsanitized(text, config), config); +}; + export default { getRows, sanitizeText, diff --git a/packages/mermaid/src/diagrams/sequence/svgDraw.js b/packages/mermaid/src/diagrams/sequence/svgDraw.js index 04ccd8a84..18fd2d034 100644 --- a/packages/mermaid/src/diagrams/sequence/svgDraw.js +++ b/packages/mermaid/src/diagrams/sequence/svgDraw.js @@ -1,8 +1,12 @@ -import common, { calculateMathMLDimensions, hasKatex, renderKatex } from '../common/common.js'; -import * as svgDrawCommon from '../common/svgDrawCommon.js'; -import { ZERO_WIDTH_SPACE, parseFontSize } from '../../utils.js'; import { sanitizeUrl } from '@braintree/sanitize-url'; import * as configApi from '../../config.js'; +import { ZERO_WIDTH_SPACE, parseFontSize } from '../../utils.js'; +import common, { + calculateMathMLDimensions, + hasKatex, + renderKatexSanitized, +} from '../common/common.js'; +import * as svgDrawCommon from '../common/svgDrawCommon.js'; export const ACTOR_TYPE_WIDTH = 18 * 2; const TOP_ACTOR_CLASS = 'actor-top'; @@ -87,13 +91,13 @@ const popupMenuToggle = function (popId) { export const drawKatex = async function (elem, textData, msgModel = null) { let textElem = elem.append('foreignObject'); - const lines = await renderKatex(textData.text, configApi.getConfig()); + const linesSanitized = await renderKatexSanitized(textData.text, configApi.getConfig()); const divElem = textElem .append('xhtml:div') .attr('style', 'width: fit-content;') .attr('xmlns', 'http://www.w3.org/1999/xhtml') - .html(lines); + .html(linesSanitized); const dim = divElem.node().getBoundingClientRect(); textElem.attr('height', Math.round(dim.height)).attr('width', Math.round(dim.width)); @@ -965,7 +969,7 @@ const _drawTextCandidateFunc = (function () { .append('div') .style('text-align', 'center') .style('vertical-align', 'middle') - .html(await renderKatex(content, configApi.getConfig())); + .html(await renderKatexSanitized(content, configApi.getConfig())); byTspan(content, s, x, y, width, height, textAttrs, conf); _setTextAttrs(text, textAttrs); diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 6dad6b214..65129aa8a 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -4,7 +4,7 @@ import { select } from 'd3'; import type { MermaidConfig } from '../config.type.js'; import { getConfig, sanitizeText } from '../diagram-api/diagramAPI.js'; import type { SVGGroup } from '../diagram-api/types.js'; -import common, { hasKatex, renderKatex } from '../diagrams/common/common.js'; +import common, { hasKatex, renderKatexSanitized } from '../diagrams/common/common.js'; import type { D3TSpanElement, D3TextElement } from '../diagrams/common/commonTypes.js'; import { log } from '../logger.js'; import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js'; @@ -29,7 +29,10 @@ async function addHtmlSpan(element, node, width, classes, addBackground = false) const div = fo.append('xhtml:div'); let label = node.label; if (node.label && hasKatex(node.label)) { - label = await renderKatex(node.label.replace(common.lineBreakRegex, '\n'), getConfig()); + label = await renderKatexSanitized( + node.label.replace(common.lineBreakRegex, '\n'), + getConfig() + ); } const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; const span = div.append('span'); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js b/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js index 482dbb9f1..de6f0403d 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/createLabel.js @@ -1,7 +1,12 @@ import { select } from 'd3'; -import { log } from '../../logger.js'; import { getConfig } from '../../diagram-api/diagramAPI.js'; -import common, { evaluate, renderKatex, hasKatex } from '../../diagrams/common/common.js'; +import common, { + evaluate, + hasKatex, + renderKatexSanitized, + sanitizeText, +} from '../../diagrams/common/common.js'; +import { log } from '../../logger.js'; import { decodeEntities } from '../../utils.js'; /** @@ -22,20 +27,21 @@ async function addHtmlLabel(node) { const fo = select(document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')); const div = fo.append('xhtml:div'); + const config = getConfig(); let label = node.label; if (node.label && hasKatex(node.label)) { - label = await renderKatex(node.label.replace(common.lineBreakRegex, '\n'), getConfig()); + label = await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config); } const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; - div.html( + const labelSpan = '' + - label + - '' - ); + labelClass + + '" ' + + (node.labelStyle ? 'style="' + node.labelStyle + '"' : '') + // codeql [js/html-constructed-from-input] : false positive + '>' + + label + + ''; + div.html(sanitizeText(labelSpan, config)); applyStyle(div, node.labelStyle); div.style('display', 'inline-block'); From 8d79bc9b195e5cfa2f31649d622024670886edff Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 5 Aug 2025 22:36:28 +0530 Subject: [PATCH 2/9] test: check katex sanitization --- cypress/helpers/util.ts | 8 +++----- cypress/integration/other/xss.spec.js | 13 ++++++++++++- cypress/platform/viewer.js | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/cypress/helpers/util.ts b/cypress/helpers/util.ts index 81b7036af..ab4bbef64 100644 --- a/cypress/helpers/util.ts +++ b/cypress/helpers/util.ts @@ -14,7 +14,7 @@ interface CodeObject { mermaid: CypressMermaidConfig; } -const utf8ToB64 = (str: string): string => { +export const utf8ToB64 = (str: string): string => { return Buffer.from(decodeURIComponent(encodeURIComponent(str))).toString('base64'); }; @@ -22,7 +22,7 @@ const batchId: string = 'mermaid-batch-' + (Cypress.env('useAppli') ? Date.now().toString() - : Cypress.env('CYPRESS_COMMIT') || Date.now().toString()); + : (Cypress.env('CYPRESS_COMMIT') ?? Date.now().toString())); export const mermaidUrl = ( graphStr: string | string[], @@ -61,9 +61,7 @@ export const imgSnapshotTest = ( sequence: { ...(_options.sequence ?? {}), actorFontFamily: 'courier', - noteFontFamily: _options.sequence?.noteFontFamily - ? _options.sequence.noteFontFamily - : 'courier', + noteFontFamily: _options.sequence?.noteFontFamily ?? 'courier', messageFontFamily: 'courier', }, }; diff --git a/cypress/integration/other/xss.spec.js b/cypress/integration/other/xss.spec.js index 1e51d2f23..7e286876b 100644 --- a/cypress/integration/other/xss.spec.js +++ b/cypress/integration/other/xss.spec.js @@ -1,4 +1,4 @@ -import { mermaidUrl } from '../../helpers/util.ts'; +import { imgSnapshotTest, mermaidUrl, utf8ToB64 } from '../../helpers/util.ts'; describe('XSS', () => { it('should handle xss in tags', () => { const str = @@ -141,4 +141,15 @@ describe('XSS', () => { cy.wait(1000); cy.get('#the-malware').should('not.exist'); }); + + it('should sanitize katex blocks', () => { + const str = JSON.stringify({ + code: `sequenceDiagram + participant A as Alice$$\\text{Alice}$$ + A->>John: Hello John, how are you?`, + }); + imgSnapshotTest(utf8ToB64(str), {}, true); + cy.wait(1000); + cy.get('#the-malware').should('not.exist'); + }); }); diff --git a/cypress/platform/viewer.js b/cypress/platform/viewer.js index e120469fe..7ff95e163 100644 --- a/cypress/platform/viewer.js +++ b/cypress/platform/viewer.js @@ -182,7 +182,7 @@ const contentLoadedApi = async function () { for (let i = 0; i < numCodes; i++) { const { svg, bindFunctions } = await mermaid.render('newid' + i, graphObj.code[i], divs[i]); div.innerHTML = svg; - bindFunctions(div); + bindFunctions?.(div); } } else { const div = document.createElement('div'); @@ -194,7 +194,7 @@ const contentLoadedApi = async function () { const { svg, bindFunctions } = await mermaid.render('newid', graphObj.code, div); div.innerHTML = svg; console.log(div.innerHTML); - bindFunctions(div); + bindFunctions?.(div); } } }; From 0133f1c0c5cff4fc4c8e0b99e9cf0b3d49dcbe71 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 5 Aug 2025 22:42:13 +0530 Subject: [PATCH 3/9] docs: Add changeset --- .changeset/tidy-weeks-play.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-weeks-play.md diff --git a/.changeset/tidy-weeks-play.md b/.changeset/tidy-weeks-play.md new file mode 100644 index 000000000..8c36f70b0 --- /dev/null +++ b/.changeset/tidy-weeks-play.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: sanitize KATEX blocks From 3256807d25dc2d418171c1f3a3c00856e31e50a9 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Tue, 5 Aug 2025 22:44:58 +0530 Subject: [PATCH 4/9] chore: Add @fourcube in changeset --- .changeset/tidy-weeks-play.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/tidy-weeks-play.md b/.changeset/tidy-weeks-play.md index 8c36f70b0..987171995 100644 --- a/.changeset/tidy-weeks-play.md +++ b/.changeset/tidy-weeks-play.md @@ -3,3 +3,5 @@ --- fix: sanitize KATEX blocks + +Resolves issue reported by @fourcube From e1e36dfcb3eee1cd7a0ee3de1810718f545b4b26 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Thu, 7 Aug 2025 13:37:54 +0530 Subject: [PATCH 5/9] chore: Add CVE ID --- .changeset/tidy-weeks-play.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-weeks-play.md b/.changeset/tidy-weeks-play.md index 987171995..266f57552 100644 --- a/.changeset/tidy-weeks-play.md +++ b/.changeset/tidy-weeks-play.md @@ -4,4 +4,4 @@ fix: sanitize KATEX blocks -Resolves issue reported by @fourcube +Resolves CVE-2025-54881 reported by @fourcube From cfc76ef1cb7eb31c3c1b3b1a3aec30b4f4433d74 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Thu, 7 Aug 2025 22:49:39 +0530 Subject: [PATCH 6/9] fix: sanitize HTML for spans --- .../mermaid/src/rendering-util/createText.ts | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 65129aa8a..9b9c9d574 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -2,9 +2,8 @@ // @ts-nocheck TODO: Fix types import { select } from 'd3'; import type { MermaidConfig } from '../config.type.js'; -import { getConfig, sanitizeText } from '../diagram-api/diagramAPI.js'; import type { SVGGroup } from '../diagram-api/types.js'; -import common, { hasKatex, renderKatexSanitized } from '../diagrams/common/common.js'; +import common, { hasKatex, renderKatexSanitized, sanitizeText } from '../diagrams/common/common.js'; import type { D3TSpanElement, D3TextElement } from '../diagrams/common/commonTypes.js'; import { log } from '../logger.js'; import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js'; @@ -19,7 +18,15 @@ function applyStyle(dom, styleFn) { } } -async function addHtmlSpan(element, node, width, classes, addBackground = false) { +async function addHtmlSpan( + element, + node, + width, + classes, + addBackground = false, + // TODO: Make config mandatory + config: MermaidConfig = {} +) { const fo = element.append('foreignObject'); // This is not the final width but used in order to make sure the foreign // object in firefox gets a width at all. The final width is fetched from the div @@ -27,16 +34,12 @@ async function addHtmlSpan(element, node, width, classes, addBackground = false) fo.attr('height', `${10 * width}px`); const div = fo.append('xhtml:div'); - let label = node.label; - if (node.label && hasKatex(node.label)) { - label = await renderKatexSanitized( - node.label.replace(common.lineBreakRegex, '\n'), - getConfig() - ); - } + const sanitizedLabel = hasKatex(label) + ? await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config) + : sanitizeText(label, config); const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; const span = div.append('span'); - span.html(label); + span.html(sanitizedLabel); applyStyle(span, node.labelStyle); span.attr('class', `${labelClass} ${classes}`); @@ -59,9 +62,6 @@ async function addHtmlSpan(element, node, width, classes, addBackground = false) bbox = div.node().getBoundingClientRect(); } - // fo.style('width', bbox.width); - // fo.style('height', bbox.height); - return fo.node(); } @@ -184,9 +184,14 @@ function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) { /** * Convert fontawesome labels into fontawesome icons by using a regex pattern * @param text - The raw string to convert + * @param config - Mermaid config * @returns string with fontawesome icons as svg if the icon is registered otherwise as i tags */ -export async function replaceIconSubstring(text: string) { +export async function replaceIconSubstring( + text: string, + // TODO: Make config mandatory + config: MermaidConfig = {} +): Promise { const pendingReplacements: Promise[] = []; // cspell: disable-next-line text.replace(/(fa[bklrs]?):fa-([\w-]+)/g, (fullMatch, prefix, iconName) => { @@ -196,7 +201,7 @@ export async function replaceIconSubstring(text: string) { if (await isIconAvailable(registeredIconName)) { return await getIconSVG(registeredIconName, undefined, { class: 'label-icon' }); } else { - return ``; + return ``; } })() ); @@ -239,7 +244,7 @@ export const createText = async ( // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? const htmlText = markdownToHTML(text, config); - const decodedReplacedText = await replaceIconSubstring(decodeEntities(htmlText)); + const decodedReplacedText = await replaceIconSubstring(decodeEntities(htmlText), config); //for Katex the text could contain escaped characters, \\relax that should be transformed to \relax const inputForKatex = text.replace(/\\\\/g, '\\'); @@ -249,7 +254,7 @@ export const createText = async ( label: hasKatex(text) ? inputForKatex : decodedReplacedText, labelStyle: style.replace('fill:', 'color:'), }; - const vertexNode = await addHtmlSpan(el, node, width, classes, addSvgBackground); + const vertexNode = await addHtmlSpan(el, node, width, classes, addSvgBackground, config); return vertexNode; } else { //sometimes the user might add br tags with 1 or more spaces in between, so we need to replace them with
From e539909e87a9c07ed9ba879bc8487b2acdbc3c85 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Fri, 8 Aug 2025 12:54:26 +0530 Subject: [PATCH 7/9] fix: Label in addHtmlSpan --- packages/mermaid/src/rendering-util/createText.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 9b9c9d574..47f8bb98b 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -34,9 +34,9 @@ async function addHtmlSpan( fo.attr('height', `${10 * width}px`); const div = fo.append('xhtml:div'); - const sanitizedLabel = hasKatex(label) + const sanitizedLabel = hasKatex(node.label) ? await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config) - : sanitizeText(label, config); + : sanitizeText(node.label, config); const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; const span = div.append('span'); span.html(sanitizedLabel); From 096fbe933e555dacb8c0f0173e419bde95052691 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Fri, 8 Aug 2025 12:55:18 +0530 Subject: [PATCH 8/9] test: Verify label is sanitized Co-authored-by: Chris Grieger --- cypress/integration/other/xss.spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cypress/integration/other/xss.spec.js b/cypress/integration/other/xss.spec.js index 7e286876b..603e75f5d 100644 --- a/cypress/integration/other/xss.spec.js +++ b/cypress/integration/other/xss.spec.js @@ -152,4 +152,15 @@ describe('XSS', () => { cy.wait(1000); cy.get('#the-malware').should('not.exist'); }); + + it('should sanitize labels', () => { + const str = JSON.stringify({ + code: `erDiagram + "" ||--|| ENTITY2 : "" + `, + }); + imgSnapshotTest(utf8ToB64(str), {}, true); + cy.wait(1000); + cy.get('#the-malware').should('not.exist'); + }); }); From 880f7454a355bfb3612dac7870d9124440c3f812 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Mon, 18 Aug 2025 16:50:53 +0530 Subject: [PATCH 9/9] fix: sanitize addHtmlLabel in createLabel Co-authored-by: Chris Grieger --- packages/mermaid/src/dagre-wrapper/createLabel.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/mermaid/src/dagre-wrapper/createLabel.js b/packages/mermaid/src/dagre-wrapper/createLabel.js index 467eed260..fdab9a34e 100644 --- a/packages/mermaid/src/dagre-wrapper/createLabel.js +++ b/packages/mermaid/src/dagre-wrapper/createLabel.js @@ -1,9 +1,9 @@ import { select } from 'd3'; -import { log } from '../logger.js'; import { getConfig } from '../diagram-api/diagramAPI.js'; -import { evaluate } from '../diagrams/common/common.js'; -import { decodeEntities } from '../utils.js'; +import { evaluate, sanitizeText } from '../diagrams/common/common.js'; +import { log } from '../logger.js'; import { replaceIconSubstring } from '../rendering-util/createText.js'; +import { decodeEntities } from '../utils.js'; /** * @param dom @@ -19,14 +19,14 @@ function applyStyle(dom, styleFn) { * @param {any} node * @returns {SVGForeignObjectElement} Node */ -function addHtmlLabel(node) { +function addHtmlLabel(node, config) { const fo = select(document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')); const div = fo.append('xhtml:div'); const label = node.label; const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; const span = div.append('span'); - span.html(label); + span.html(sanitizeText(label, config)); applyStyle(span, node.labelStyle); span.attr('class', labelClass); @@ -49,7 +49,8 @@ const createLabel = async (_vertexText, style, isTitle, isNode) => { if (typeof vertexText === 'object') { vertexText = vertexText[0]; } - if (evaluate(getConfig().flowchart.htmlLabels)) { + const config = getConfig(); + if (evaluate(config.flowchart.htmlLabels)) { // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? vertexText = vertexText.replace(/\\n|\n/g, '
'); log.debug('vertexText' + vertexText); @@ -59,7 +60,7 @@ const createLabel = async (_vertexText, style, isTitle, isNode) => { label, labelStyle: style.replace('fill:', 'color:'), }; - let vertexNode = addHtmlLabel(node); + let vertexNode = addHtmlLabel(node, config); // vertexNode.parentNode.removeChild(vertexNode); return vertexNode; } else {