I recently shipped Mermaid diagram support to Forem, the open source platform that powers DEV. Fenced mermaid code blocks now render as actual diagrams instead of plain highlighted source.
Which means this renders, right here, on this post:
flowchart TD
A[CodeQL flags innerHTML] --> B[Add DOMPurify]
B --> C{Measure the output}
C -->|assumed| D[Ship it]
C -->|actually| E[Labels are gone]
E --> F[Find the real bug]
The feature itself is not the interesting part. The interesting part is a security alert with an obvious one-line fix, where applying that obvious fix would have quietly destroyed the labels on four of the five most common diagram types. Nothing would have crashed. No test would have failed. The diagrams would just have come out wrong.
The feature, briefly
Forem renders Markdown through Redcarpet with a Rouge highlighter. Rouge already receives the language hint, so intercepting block_code is enough to emit different markup for Mermaid. The browser then renders it.
Two things bit me before I ever got to the security question, and both are worth knowing if you contribute to Forem.
Gotcha one: the sanitizer eats your class
The natural markup is <pre class="mermaid">, which is what Mermaid's own documentation uses. On Forem it does not work, and it fails in the most annoying way possible: silently.
Every piece of rendered Markdown passes through a scrubber with an allow list, and class is not on it. The attribute is stripped before the HTML ever reaches the browser, so the client side script finds nothing and no error is raised anywhere. You are left staring at correct-looking server output and a page that does nothing.
data-lang is on the allow list. So the markup becomes <pre data-lang="mermaid"> and the script targets pre[data-lang="mermaid"] instead.
This also settled a design question. Rendering the SVG on the server would avoid shipping a JavaScript library at all, and the allowed tag list does include svg, path and g, so at a glance it looks viable. It is not. The list is missing text, tspan, foreignObject, marker and style, which is to say all the labels, all the arrowheads and all the styling. Server rendered diagrams would have arrived mangled.
Gotcha two: your diagram source is not safe from the emoji parser
Forem post-processes rendered HTML. Among other things it converts :smile: into an emoji and turns @name into a profile link. Both of those are perfectly reasonable for prose and actively destructive for diagram source, where a sequence diagram label might legitimately contain either.
Both post-processors skip code nodes. So the source goes inside a <code> wrapper within the <pre>, which costs nothing on the client because textContent reads through it anyway.
Now the interesting part
With the feature working and the pull request open, GitHub's CodeQL analysis flagged a line:
// CodeQL: DOM text reinterpreted as HTML
figure.innerHTML = svg;
The taint path is real and easy to state. Diagram source is written by users. It flows into mermaid.render(). What comes back is assigned to innerHTML. User input reaches the DOM as markup.
There is a defence already in place. Mermaid runs with securityLevel: 'strict', under which it sanitizes its own output with DOMPurify. I do not believe the code was exploitable as written. But relying entirely on a third party library's internal sanitization for untrusted input is exactly the pattern behind Mermaid's own historical XSS advisories, so leaning on it felt like the wrong answer.
The obvious fix
Sanitize explicitly. This is a two line change and it is what almost every article on this class of finding will tell you to do:
import DOMPurify from 'dompurify';
const SANITIZE_CONFIG = { USE_PROFILES: { svg: true, svgFilters: true } };
figure.innerHTML = DOMPurify.sanitize(svg, SANITIZE_CONFIG);
CodeQL goes green. The taint path is broken. Ship it.
What happened when I measured
Before committing, I wanted to confirm the sanitizer was transparent, meaning it removed nothing legitimate. So I rendered a handful of diagrams, sanitized the output, and compared element counts on each side.
[flowchart] LOST div:5->1, foreignobject:4->0, span:4->0, p:4->0
[sequence] nothing lost
[class] LOST div:4->1, foreignobject:3->0, span:3->0, p:3->0
[state] LOST div:3->1, foreignobject:2->0, span:2->0, p:2->0
[er] LOST div:3->1, foreignobject:2->0, span:2->0, p:2->0
DOMPurify was stripping every foreignObject, and with it the div, span and p nodes inside. Those elements are how Mermaid draws HTML labels. The diagrams would still have rendered. Boxes, arrows and layout all intact. Just no text in them, on four of the five types I tested.
DOMPurify is not being unreasonable here. foreignObject lets you embed HTML inside SVG, which makes it a namespace confusion vector, so DOMPurify removes it and will keep removing it whatever profile you pass. My own attack probe confirmed why that matters:
script -> <svg><text>hi</text></svg>
onload -> <svg><g><text>hi</text></g></svg>
onerror -> <svg><image href="x"></image></svg>
javascript: -> <svg><a><text>hi</text></a></svg>
foreignObject -> <svg></svg>
That last line is the payload that only got neutralised because foreignObject was removed. Adding it back to an allow list to save my labels would have reopened the exact hole I was trying to close.
The actual root cause
At this point the obvious conclusion is that sanitizing and supporting labels are in conflict. They are not. The real question is why foreignObject was in the output at all, because I had already configured Mermaid to avoid HTML labels:
mermaid.initialize({
securityLevel: 'strict',
flowchart: { htmlLabels: false },
class: { htmlLabels: false },
});
Those nested settings do nothing. I only found out by counting elements in the rendered output rather than trusting the configuration:
nested flowchart.htmlLabels: false foreignObject=3 text=2
top-level htmlLabels: false foreignObject=0 text=5
Set at the top level, htmlLabels works, and labels come out as real SVG text nodes. Two labels became five, because the three that had been hidden inside foreignObject wrappers are now first class SVG.
So the configuration I believed was hardening the renderer had been silently inert the whole time. The security fix did not create that bug. It exposed it.
The result
With htmlLabels corrected, sanitization removes nothing at all. I verified across every diagram type I could think of by comparing tag counts before and after:
flowchart foreignObject=0 lossless
sequence foreignObject=0 lossless
class foreignObject=0 lossless
state foreignObject=0 lossless
er foreignObject=0 lossless
gantt foreignObject=0 lossless
pie foreignObject=0 lossless
Two problems, one fix. The renderer no longer emits an element class that is a known XSS vector, and the explicit sanitizer can run without costing anything.
What I took away
- A sanitizer is a transformation, not a no-op. Adding one changes your output. If you do not diff before and after, you are guessing about what it took away.
-
Config that fails silently is worse than config that errors.
flowchart.htmlLabelswas accepted, ignored, and reported nothing. It looked like hardening and did nothing at all. - Static analysis findings are worth investigating, not just closing. CodeQL pointed at a line that was probably fine. Chasing it properly surfaced a real defect two layers down.
- The dangerous bugs are the quiet ones. A crash gets fixed in an hour. Diagrams that render with missing labels ship, and then sit there.
The feature is live
The pull request was merged into Forem on August 25, 2026. It was reviewed and merged by Ben Halpern, who founded DEV, and I will admit the review comment made my week.
Every Mermaid block in this post is rendering through that code right now, no image export step required, which was the whole point: people cross-posting from a static blog no longer have to maintain a separate version of every article.
If you want to read the implementation, it is pull request #23764, against issue #23671.
Top comments (0)