/** * Mermaid renders MODEL-authored diagram source on the chat path, and the * result is injected with `dangerouslySetInnerHTML`. This fixture pins the * hardening in ../mermaid-diagram.tsx: a hostile node label must NOT survive * as live HTML. * * The test drives `mermaid` with the SAME security knobs the component sets, * by IMPORTING them: `MERMAID_SECURITY_OPTIONS` is the single source of truth * and is spread into both `mermaid.initialize` calls. There are no literals * here to drift from the component — flipping `securityLevel` to `'loose'` in * the constant fails this suite (verified). * * `secure` is part of that constant: it is the allowlist of config keys a * `%%{init}%%` directive inside the diagram SOURCE may not override. mermaid's * default list omits `htmlLabels`, so untrusted source could otherwise * re-enable HTML labels while `securityLevel` stayed locked. * * jsdom NOTE (recorded, not proven): before `htmlLabels` was added to `secure`, * a `%%{init: {"htmlLabels": true}}%%` directive was observed to make * `mermaid.render` never settle under jsdom (>60s, against a passing * two-render control). That was NOT reproduced in a real browser and may be an * artifact of jsdom having no layout — it is NOT a confirmed browser DoS. The * component wraps its render in a timeout regardless. */ import { beforeAll, describe, expect, it } from 'vitest' import { MERMAID_SECURITY_OPTIONS } from '../mermaid-diagram' // jsdom implements no SVG layout, and mermaid's dagre pass measures text. // These stubs are only about making the renderer RUN; they do not touch the // sanitization path under test. function installSvgLayoutStubs(): void { const proto = (globalThis as unknown as { SVGElement?: { prototype: Record } }).SVGElement?.prototype if (!proto) return proto.getBBox = function getBBox() { return { x: 0, y: 0, width: 100, height: 20 } } proto.getComputedTextLength = function getComputedTextLength() { return 100 } proto.getScreenCTM = function getScreenCTM() { return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0, inverse: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) } } } // Second tuple slot: text that MUST be present in the rendered output — a // positive control proving the diagram was actually drawn, so the negative // assertions below can't pass on empty output. Note the two payloads are // neutralized differently: the `` survives as ESCAPED label text, while // the `"] --> B["ok"]', 'ok'], // A `%%{init}%%` directive trying to unlock the two knobs. Both keys are in // `secure`, so the override is rejected and the payload stays escaped. [ '%%{init:{"securityLevel":"loose"}}%%\ngraph TD\n A[""] --> B["ok"]', '"] --> B["ok"]', '"] --> B["ok"]', ' B["b"]\n click A "javascript:alert(1)"', 'ok'], ] /** Initialize with the COMPONENT's security options — imported, never * re-typed — so the fixture cannot drift from the renderer. */ function initializeLikeComponent(mermaid: { initialize: (c: Record) => void }): void { mermaid.initialize({ startOnLoad: false, theme: 'dark', flowchart: { useMaxWidth: true }, ...MERMAID_SECURITY_OPTIONS, }) } describe('mermaid hostile-label hardening', () => { beforeAll(() => { installSvgLayoutStubs() }) it.each(HOSTILE_LABELS)('does not emit live HTML for %j', async (chart, expectedText) => { const { default: mermaid } = await import('mermaid') initializeLikeComponent(mermaid) const { svg } = await mermaid.render(`mermaid-security-${Math.random().toString(36).slice(2)}`, chart) // No live element, and no event-handler attribute, anywhere in the output. expect(svg).not.toMatch(/]/i) expect(svg).not.toMatch(/]/i) expect(svg).not.toMatch(/\son[a-z]+\s*=/i) // `htmlLabels: false` means no HTML subtree is minted for labels at all. expect(svg).not.toMatch(/` inside * the SVG the component hands to `dangerouslySetInnerHTML`, and mermaid's * directive sanitizer does not defend it: it is an ordinary config key whose * value only gets brace-balanced. Before `themeCSS` was added to `secure`, * both payloads below reproduced against real mermaid 11.14.0 — the first * emitted `#svgId{position:fixed;…;z-index:2147483647;}` (an opaque top-most * viewport-filling overlay whose visible text the author writes in the node * labels), the second emitted a document-global `@font-face` fetching from an * attacker host, because at-rules escape the `#svgId` prefix entirely. * * Diagram source on the chat path is MODEL output, so this is reachable from * untrusted input. These cases fail if `themeCSS` leaves `secure`. */ const HOSTILE_THEME_CSS: ReadonlyArray = [ [ 'viewport-overlay via bare declarations', // The label is deliberately neutral: in the real attack it reads // "Session expired, sign in at ", but putting a hostname in // the label would trip this suite's own `not.toContain('evil.example')` // assertion on legitimate TEXT rather than on injected CSS. '%%{init:{"themeCSS":"position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:2147483647;background:#fff"}}%%\ngraph TD\n A["Session expired. Sign in again."] --> B["ok"]', ], [ 'document-global at-rule escaping the #svgId scope', '%%{init:{"themeCSS":"@font-face { font-family: e; src: url(https://evil.example/f.woff) }"}}%%\ngraph TD\n A["ok"] --> B["b"]', ], [ 'the YAML front-matter config channel', '---\nconfig:\n themeCSS: "position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:2147483647"\n---\ngraph TD\n A["ok"] --> B["b"]', ], ] describe('mermaid themeCSS hardening', () => { beforeAll(() => { installSvgLayoutStubs() }) it.each(HOSTILE_THEME_CSS)('rejects a themeCSS override — %s', async (_label, chart) => { const { default: mermaid } = await import('mermaid') initializeLikeComponent(mermaid) const { svg } = await mermaid.render( `mermaid-themecss-${Math.random().toString(36).slice(2)}`, chart, ) expect(svg).not.toMatch(/position\s*:\s*fixed/i) expect(svg).not.toContain('2147483647') expect(svg).not.toMatch(/@font-face/i) expect(svg).not.toContain('evil.example') // Positive control: the diagram still rendered, so the assertions above // cannot be passing on empty output. const host = document.createElement('div') host.innerHTML = svg expect(host.textContent ?? '').toContain('ok') }) })