fix(preview): keep Mermaid labels out of foreignObject so the filter cannot delete them - #455
Merged
PathGao merged 1 commit intoAug 5, 2026
Conversation
…cannot delete them
Every diagram whose labels Mermaid put in a `<foreignObject>` rendered as empty
shapes — in the live preview and in the exported HTML alike, because both call
`renderRichContent` → `sanitizeDiagramSvg`.
## Mechanism
`sanitizeDiagramSvg` allowed the `foreignObject` *element*:
DOMPurify.sanitize(svg, { ADD_TAGS: ['foreignObject'], ADD_ATTR: [...] })
The label is not the element. It is the HTML inside it — `<div
class="labelBkg">…<span class="nodeLabel">Alpha</span></div>` — and DOMPurify
deletes HTML children of an SVG element unless the parent is an HTML integration
point:
// dompurify 3.4.12, dist/purify.es.mjs
const HTML_INTEGRATION_POINTS = freeze(['annotation-xml']);
_checkHtmlNamespace = function (tagName, parent, parentTagName) {
if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName])
return false;
`foreignobject` is not on that list, so no `ADD_TAGS` entry could save its
children. `DOMPurify.removed` grew one entry per label and what survived was
literally `<foreignObject width="37.98" height="24"></foreignObject>`.
## Scope, measured
Real mermaid 11.16.0 driven by real dompurify 3.4.12 in a browser, over every
diagram type 11.16.0 ships a renderer for — not only the five sampled in the
report:
labels entirely deleted (10) flowchart, flowchart-v2, classDiagram,
stateDiagram, stateDiagram-v2, erDiagram,
requirementDiagram, mindmap, block, kanban
labels hidden (1) journey — see the `<switch>` note below
unaffected sequence, gantt, pie, quadrantChart, gitGraph,
C4Context, timeline, sankey, xychart,
architecture, info, ishikawa, wardley, treemap,
packet, radar, treeView — all SVG `<text>`
## The fix
`mermaid.initialize` now passes `htmlLabels: false`. Mermaid then emits SVG
`<text>`, which no sanitizer objects to; every label above survives. One
root-level key is enough — the per-diagram `flowchart.htmlLabels` /
`class.htmlLabels` / … settings are deprecated in 11.x and the root one takes
precedence over them — so the diagram-specific keys are deliberately not set.
Because nothing then depends on HTML inside the SVG, `foreignObject` also leaves
`sanitizeDiagramSvg`'s `ADD_TAGS`. Nothing needs it:
- Mermaid still emits `foreignObject` unconditionally in three places — venn
`text` nodes, eventmodeling boxes, architecture `iconText` — but their HTML
children are deleted by the rule above whether or not the tag is allowed, so
the allowance could only ever produce an empty box. Measured: venn's "Bravo"
label is absent with the tag allowed and absent without it.
- The `<switch>`-based renderers (journey by default, and anything configured
`textPlacement: 'fo'`) pair the `foreignObject` with an SVG `<text>`
fallback. A browser renders the first child it supports, so an
emptied-but-present `foreignObject` *suppressed* a label that had come
through the filter intact. Measured: the journey's task labels are 0×0 with
the tag on the allowlist and 37×20 with it removed. Dropping it fixes a
diagram `htmlLabels: false` alone cannot.
So the filter gets smaller, not larger.
## The rejected alternative
DOMPurify 3.4.12 accepts `HTML_INTEGRATION_POINTS` as a config option, so
allowing `foreignObject` to be an integration point is a candidate fix that
would have preserved rendering fidelity exactly. Two findings:
- It only works as an object map. `HTML_INTEGRATION_POINTS: ['annotation-xml',
'foreignobject']` is a silent no-op — the value is `clone()`d, and cloning an
array yields index keys, which also drops `annotation-xml`. Passing
`{ 'annotation-xml': true, foreignobject: true }` does restore every label.
- It is the wrong trade. It re-permits HTML inside SVG, which DOMPurify keeps
out on purpose because serialise-then-reparse is the mutation-XSS primitive —
and `container.innerHTML = sanitizeDiagramSvg(svg)` is exactly that reparse.
Mermaid source is document content, so the SVG is attacker-influenced; #384
exists in this same cycle because a document's `<style>` reached the app's own
DOM. Measured difference: with the override, `<svg><foreignObject><img
src=x></foreignObject></svg>` survives sanitisation and materialises as a
live `<img>` on the reparse, where today (and with this fix) it does not.
## Trade-offs of `htmlLabels: false`
- Markup inside a node label renders literally: `A["<b>bold</b> text"]` comes
out as the six characters `<b>` followed by `bold`. No sample, test or doc in
this repo puts HTML in a Mermaid label (checked: `samples/` has one diagram,
`graph TD` with plain labels).
- KaTeX inside a label goes the same way — Mermaid's math path runs only under
`useHtmlLabels` — so `A["$$x^2$$"]` renders as its source. It rendered as
nothing at all before this change, so this is not a loss.
- Wrapping is measured by the SVG text engine instead of the browser's layout,
so long labels break at slightly different points.
## Not a v2.7.0 regression
The allowance predates this cycle: it was introduced in eb9a1c7 ("Fix Mermaid
diagram rendering with SVG foreignObject support", 2026-02-04) and #411 only
moved it into `richContent.ts`. v2.6.13 ships the byte-identical config, and the
dompurify it shipped with (3.3.1) has the same `addToSet({}, ['annotation-xml'])`
and the same SVG-namespace check. So the release does not have to hold for this.
## Tests
`scripts/mermaidDiagramLabels.test.ts` runs the real `renderRichContent` over a
real code block with Mermaid answered out of `scripts/mermaidDiagramCorpus.json`
— bytes real mermaid 11.16.0 emitted, captured once with the old config and once
with the new one, and keyed by the config the pipeline actually sends, so a
config nobody has measured is an error rather than a pass. It parses what the
pipeline produced and asserts there is no HTML in the SVG for the namespace rule
to reach and that every label is in an SVG `<text>`.
What it cannot execute is DOMPurify: without a DOM the library returns a bare
factory with no `sanitize` at all, so it is stood in for by the identity function
as `exportRichContent.test.ts` already does, and a third test asserts that state
so the middle one is not misread as "the filter kept the labels". Making that
half real needs a DOM faithful enough to reproduce HTML5 foreign-content parsing,
which is the rule under test — a shim written here would be marking its own
homework, and no test-only DOM dependency was added.
Falsified: with only `htmlLabels: false` reverted, the corpus stand-in returns
the old bytes and the middle test fails with `flowchart: the rendered diagram
still carries its labels in foreignObject, whose HTML children DOMPurify removes
regardless of ADD_TAGS`, 3 !== 0.
`previewSanitize.test.ts` asserted the source text `ADD_TAGS: ['foreignObject']`.
That assertion confirmed a config string existed while every label was being
stripped, so it is replaced rather than re-anchored: the split between the two
sanitizer configs is now pinned on the reason that survives — the diagram filter
must permit the `<style>` the document policy forbids.
The rationale comment in `scripts/sourceTree.ts` still says the diagram config
"needs `foreignObject`"; it is left for #454's rewrite of that tree rather than
conflicting with it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every diagram whose labels Mermaid put in a
<foreignObject>rendered as emptyshapes — in the live preview and in the exported HTML alike, because both call
renderRichContent→sanitizeDiagramSvg.Mechanism
sanitizeDiagramSvgallowed theforeignObjectelement:The label is not the element. It is the HTML inside it —
<div class="labelBkg">…<span class="nodeLabel">Alpha</span></div>— and DOMPurifydeletes HTML children of an SVG element unless the parent is an HTML integration
point:
foreignobjectis not on that list, so noADD_TAGSentry could save itschildren.
DOMPurify.removedgrew one entry per label and what survived wasliterally
<foreignObject width="37.98" height="24"></foreignObject>.Scope, measured
Real mermaid 11.16.0 driven by real dompurify 3.4.12 in a browser, over every
diagram type 11.16.0 ships a renderer for — not only the five sampled in the
report:
labels entirely deleted (10) flowchart, flowchart-v2, classDiagram,
stateDiagram, stateDiagram-v2, erDiagram,
requirementDiagram, mindmap, block, kanban
labels hidden (1) journey — see the
<switch>note belowunaffected sequence, gantt, pie, quadrantChart, gitGraph,
C4Context, timeline, sankey, xychart,
architecture, info, ishikawa, wardley, treemap,
packet, radar, treeView — all SVG
<text>The fix
mermaid.initializenow passeshtmlLabels: false. Mermaid then emits SVG<text>, which no sanitizer objects to; every label above survives. Oneroot-level key is enough — the per-diagram
flowchart.htmlLabels/class.htmlLabels/ … settings are deprecated in 11.x and the root one takesprecedence over them — so the diagram-specific keys are deliberately not set.
Because nothing then depends on HTML inside the SVG,
foreignObjectalso leavessanitizeDiagramSvg'sADD_TAGS. Nothing needs it:foreignObjectunconditionally in three places — venntextnodes, eventmodeling boxes, architectureiconText— but their HTMLchildren are deleted by the rule above whether or not the tag is allowed, so
the allowance could only ever produce an empty box. Measured: venn's "Bravo"
label is absent with the tag allowed and absent without it.
<switch>-based renderers (journey by default, and anything configuredtextPlacement: 'fo') pair theforeignObjectwith an SVG<text>fallback. A browser renders the first child it supports, so an
emptied-but-present
foreignObjectsuppressed a label that had comethrough the filter intact. Measured: the journey's task labels are 0×0 with
the tag on the allowlist and 37×20 with it removed. Dropping it fixes a
diagram
htmlLabels: falsealone cannot.So the filter gets smaller, not larger.
The rejected alternative
DOMPurify 3.4.12 accepts
HTML_INTEGRATION_POINTSas a config option, soallowing
foreignObjectto be an integration point is a candidate fix thatwould have preserved rendering fidelity exactly. Two findings:
HTML_INTEGRATION_POINTS: ['annotation-xml', 'foreignobject']is a silent no-op — the value isclone()d, and cloning anarray yields index keys, which also drops
annotation-xml. Passing{ 'annotation-xml': true, foreignobject: true }does restore every label.out on purpose because serialise-then-reparse is the mutation-XSS primitive —
and
container.innerHTML = sanitizeDiagramSvg(svg)is exactly that reparse.Mermaid source is document content, so the SVG is attacker-influenced; fix(preview): route the preview through the shared sanitize contract #384
exists in this same cycle because a document's
<style>reached the app's ownDOM. Measured difference: with the override,
<svg><foreignObject><img src=x></foreignObject></svg>survives sanitisation and materialises as alive
<img>on the reparse, where today (and with this fix) it does not.Trade-offs of
htmlLabels: falseA["<b>bold</b> text"]comesout as the six characters
<b>followed bybold. No sample, test or doc inthis repo puts HTML in a Mermaid label (checked:
samples/has one diagram,graph TDwith plain labels).useHtmlLabels— soA["$$x^2$$"]renders as its source. It rendered asnothing at all before this change, so this is not a loss.
so long labels break at slightly different points.
Not a v2.7.0 regression
The allowance predates this cycle: it was introduced in eb9a1c7 ("Fix Mermaid
diagram rendering with SVG foreignObject support", 2026-02-04) and #411 only
moved it into
richContent.ts. v2.6.13 ships the byte-identical config, and thedompurify it shipped with (3.3.1) has the same
addToSet({}, ['annotation-xml'])and the same SVG-namespace check. So the release does not have to hold for this.
Tests
scripts/mermaidDiagramLabels.test.tsruns the realrenderRichContentover areal code block with Mermaid answered out of
scripts/mermaidDiagramCorpus.json— bytes real mermaid 11.16.0 emitted, captured once with the old config and once
with the new one, and keyed by the config the pipeline actually sends, so a
config nobody has measured is an error rather than a pass. It parses what the
pipeline produced and asserts there is no HTML in the SVG for the namespace rule
to reach and that every label is in an SVG
<text>.What it cannot execute is DOMPurify: without a DOM the library returns a bare
factory with no
sanitizeat all, so it is stood in for by the identity functionas
exportRichContent.test.tsalready does, and a third test asserts that stateso the middle one is not misread as "the filter kept the labels". Making that
half real needs a DOM faithful enough to reproduce HTML5 foreign-content parsing,
which is the rule under test — a shim written here would be marking its own
homework, and no test-only DOM dependency was added.
Falsified: with only
htmlLabels: falsereverted, the corpus stand-in returnsthe old bytes and the middle test fails with
flowchart: the rendered diagram still carries its labels in foreignObject, whose HTML children DOMPurify removes regardless of ADD_TAGS, 3 !== 0.previewSanitize.test.tsasserted the source textADD_TAGS: ['foreignObject'].That assertion confirmed a config string existed while every label was being
stripped, so it is replaced rather than re-anchored: the split between the two
sanitizer configs is now pinned on the reason that survives — the diagram filter
must permit the
<style>the document policy forbids.The rationale comment in
scripts/sourceTree.tsstill says the diagram config"needs
foreignObject"; it is left for #454's rewrite of that tree rather thanconflicting with it.
Co-authored-by: Claude Opus 5 noreply@anthropic.com
Verification
Also confirmed in a local macOS build of this branch merged onto
master:flowchart and classDiagram labels are back in the live preview and in exported
HTML.
Timing: I'd like this in v2.7.0 rather than after it — #411 puts Mermaid
export in the release notes, and today a flowchart exports as a row of empty
boxes. It is not a regression from this cycle (see above), so it is your call
either way.
Known and not fixed here: switching the app theme does not recolour diagrams
that are already on screen; they keep the old theme until the file is reopened.
Two independent causes, one of them in
MarkdownViewer.svelte. Separate PR.