
Markdown + Mermaid is a Forge app for Confluence Cloud. It adds two macros, and both render in the browser.
mermaid code block becomes a diagram.The app has no backend, no storage and no egress. All JavaScript ships as Forge static resources, so it's built to qualify for Runs on Atlassian. Confluence still applies the badge automatically after deploy, and you can check it with forge eligibility.
---
Confluence page ──► Custom UI iframe (static/ui/build/view)
├─ @forge/bridge view.getContext() → macro config {source, theme, maxHeight, showSource}
├─ marked (GFM) + marked-footnote + highlight.js core (20 languages), with a cost guard (render.js RENDER_GUARD)
├─ DOMPurify (HTML profile, hooks for links/images/inputs, SANITIZE_NAMED_PROPS)
└─ mermaid (lazy chunk, prefetched while Markdown renders; securityLevel 'strict',
locked themeCSS/fontFamily/dompurifyConfig, image-free labels) → DOMPurify SVG pass + CSS clean
Macro editor ──────► Custom UI config (static/ui/build/config): editor + options + live preview → view.submit({config})
There's no resolver and no src/index.js. The app has no backend functions, so permissions.scopes is [] and it has no external permission.
Config values are saved as strings in the macro's parameters, which Confluence stores in the page itself. The app has no storage of its own.
Scripts load only from the app's own resource directory ('self'). The manifest sets permissions.content.styles: [unsafe-inline] because Mermaid writes <style> elements and style attributes into its SVGs. Raw HTML in Markdown can't use it, since style tags and attributes are stripped from Markdown output. In diagram labels, author style attributes and images are removed before Mermaid draws them.
Mermaid's own CSS (from style/classDef statements) stays. But before Mermaid sees the diagram, every url(, image(, image-set(, cross-fade(, element(, src( and @import in its source gets an invisible word joiner (U+2060), so a browser doesn't treat it as CSS that loads a file.
One case is left alone. In flowchart/graph, architecture and mindmap diagrams, a node or service id written before its shape (src(Source) --> build, service url(internet)[Web]) stays as written, because Mermaid's parser would reject the joiner there. The exception only applies outside quotes, brackets, HTML tags, frontmatter, directives and comments. It never applies on lines with classDef/style/linkStyle/class, or after : or ,. Mermaid's flowchart parser also rejects ( in style/classDef values.
Flowchart image shapes (A@{ img: "https://…" }) get an error before Mermaid runs, because Mermaid would load that image during layout. Base64 PNG/GIF/JPEG/WebP data URLs work. Diagrams that spell these functions with CSS escapes (\75 rl() get an error too. The final SVG is cleaned again, with remote url(), @import and fixed/sticky positioning removed, and directives can't set themeCSS or fontFamily.
| Threat | Mitigation | Test |
|---|---|---|
<script>, <iframe>, <object>, <form>, <style>, <base>, <meta> in Markdown | DOMPurify plus FORBID_TAGS | render.test.js sanitization suite |
javascript: / vbscript: / data: links (incl. entity-encoded, mixed case) | DOMPurify URI allow-list | covered |
onerror / onclick / onload / ontoggle handlers | DOMPurify | covered |
| Raw SVG/MathML (mXSS vectors) in Markdown | forbidden in Markdown; only Mermaid's SVG is allowed, via a separate SVG profile | covered |
DOM clobbering (id/name) | SANITIZE_NAMED_PROPS (ids get the user-content- prefix) | covered |
Tracking pixels / egress via <img> | URL resolved like a browser does (WHATWG URL); only same-origin or base64 raster data images load, others shown as a link | fix1-regression.test.js F03 |
Egress via <video poster>, <audio>, <source>, <track> | tags removed | F03 |
| Forged diagram slots / "blocked image" links in raw HTML | app classes (mm-*) and data attributes removed from author HTML; diagram slots carry a per-render random nonce | C09, C10 |
| Mermaid label injection | securityLevel: 'strict', label DOMPurify config without images/styles (locked via secure), plus a DOMPurify SVG pass (no <a>, no href, no <script>) | dom.test.js, F04 |
| CSS injection / egress via Mermaid directives | themeCSS, fontFamily, dompurifyConfig locked; remote url(), @import, fixed positioning removed from SVG CSS | F04 |
Egress via classDef / style / themeVariables CSS while Mermaid lays out the diagram | url(, image(, image-set(, cross-fade(, element(, src(, @import neutralized in the diagram source before Mermaid runs (node or service ids before a shape excepted, see CSP above); CSS-escaped forms rejected | fix2-regression.test.js R2-01, fix3-regression.test.js R3-01 |
Egress via a flowchart image shape (@{ img: "https://…" }), which Mermaid loads with new Image() during layout | Refused before Mermaid runs (precheck and the guarded Mermaid handle); data URLs allowed | fix3-regression.test.js R3-02 |
| Deep-nesting DoS | raw HTML nesting capped at 100 levels; lists/quotes at 24; a sanitizer failure falls back to escaped text | covered |
Quadratic Markdown parsing (e.g. *a_ × 70,000 took 311 s) | marked's emphasis search is metered (deterministic work units, per paragraph and per page); a paragraph over the limit is shown as plain text with a notice. The e-mail lookahead cost is estimated in O(n) before marked runs | F01, R2-03 |
| Huge input | 1 MB input cap, 50 diagrams, 50k chars and 500 edges per diagram, 5,000 table rows / list items, 2,000 linked footnotes, highlighting off above 50k chars | covered |
Zovo builds and maintains this app. If it saves you time, you can support the work on GitHub Sponsors. Sponsorship is optional, and the tool works the same without it.
Zovo is made by theluckystrike on GitHub.
If this app saves you time, you can support the work on GitHub Sponsors. Monthly tiers start at $5, and one-time tiers start at $15. Sponsorship is optional, and the tool works the same without it.