diff --git a/.changeset/tidy-weeks-play.md b/.changeset/tidy-weeks-play.md
new file mode 100644
index 000000000..266f57552
--- /dev/null
+++ b/.changeset/tidy-weeks-play.md
@@ -0,0 +1,7 @@
+---
+'mermaid': patch
+---
+
+fix: sanitize KATEX blocks
+
+Resolves CVE-2025-54881 reported by @fourcube
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..603e75f5d 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,26 @@ 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');
+ });
+
+ 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');
+ });
});
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);
}
}
};
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 {
diff --git a/packages/mermaid/src/diagrams/common/common.ts b/packages/mermaid/src/diagrams/common/common.ts
index 4b73bb02f..045a729f7 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..47f8bb98b 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, renderKatex } 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,13 +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 renderKatex(node.label.replace(common.lineBreakRegex, '\n'), getConfig());
- }
+ const sanitizedLabel = hasKatex(node.label)
+ ? await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config)
+ : sanitizeText(node.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}`);
@@ -56,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();
}
@@ -181,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) => {
@@ -193,7 +201,7 @@ export async function replaceIconSubstring(text: string) {
if (await isIconAvailable(registeredIconName)) {
return await getIconSVG(registeredIconName, undefined, { class: 'label-icon' });
} else {
- return ``;
+ return ``;
}
})()
);
@@ -236,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, '\\');
@@ -246,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
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');