Skip to content

fix(preview): keep Mermaid labels out of foreignObject so the filter cannot delete them - #455

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/mermaid-labels-survive-sanitize
Aug 5, 2026
Merged

fix(preview): keep Mermaid labels out of foreignObject so the filter cannot delete them#455
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/mermaid-labels-survive-sanitize

Conversation

@PathGao

@PathGao PathGao commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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
renderRichContentsanitizeDiagramSvg.

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; 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 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


Verification

npm test    601 / 601
npm run check   639 files, 0 errors, 0 warnings
cargo test  154 / 154
npm audit   0 vulnerabilities

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.

…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>
@PathGao
PathGao merged commit fb9f6e3 into sftwrdotdev:master Aug 5, 2026
4 checks passed
@PathGao
PathGao deleted the fix/mermaid-labels-survive-sanitize branch August 5, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant