mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-18 14:59:53 +02:00
Merge commit from fork
fix: Sanitize KATEX blocks
This commit is contained in:
7
.changeset/tidy-weeks-play.md
Normal file
7
.changeset/tidy-weeks-play.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'mermaid': patch
|
||||
---
|
||||
|
||||
fix: sanitize KATEX blocks
|
||||
|
||||
Resolves CVE-2025-54881 reported by @fourcube
|
@@ -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',
|
||||
},
|
||||
};
|
||||
|
@@ -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<img src="x" onerror="xssAttack()">$$\\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
|
||||
"<img src=x onerror=xssAttack()>" ||--|| ENTITY2 : "<img src=x onerror=xssAttack()>"
|
||||
`,
|
||||
});
|
||||
imgSnapshotTest(utf8ToB64(str), {}, true);
|
||||
cy.wait(1000);
|
||||
cy.get('#the-malware').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -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, '<br />');
|
||||
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 {
|
||||
|
@@ -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<string> => {
|
||||
const renderKatexUnsanitized = async (text: string, config: MermaidConfig): Promise<string> => {
|
||||
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<string> => {
|
||||
return sanitizeText(await renderKatexUnsanitized(text, config), config);
|
||||
};
|
||||
|
||||
export default {
|
||||
getRows,
|
||||
sanitizeText,
|
||||
|
@@ -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);
|
||||
|
@@ -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<string> {
|
||||
const pendingReplacements: Promise<string>[] = [];
|
||||
// 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 `<i class='${sanitizeText(fullMatch).replace(':', ' ')}'></i>`;
|
||||
return `<i class='${sanitizeText(fullMatch, config).replace(':', ' ')}'></i>`;
|
||||
}
|
||||
})()
|
||||
);
|
||||
@@ -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 <br/>
|
||||
|
@@ -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 =
|
||||
'<span class="' +
|
||||
labelClass +
|
||||
'" ' +
|
||||
(node.labelStyle ? 'style="' + node.labelStyle + '"' : '') + // codeql [js/html-constructed-from-input] : false positive
|
||||
'>' +
|
||||
label +
|
||||
'</span>'
|
||||
);
|
||||
labelClass +
|
||||
'" ' +
|
||||
(node.labelStyle ? 'style="' + node.labelStyle + '"' : '') + // codeql [js/html-constructed-from-input] : false positive
|
||||
'>' +
|
||||
label +
|
||||
'</span>';
|
||||
div.html(sanitizeText(labelSpan, config));
|
||||
|
||||
applyStyle(div, node.labelStyle);
|
||||
div.style('display', 'inline-block');
|
||||
|
Reference in New Issue
Block a user