From 625b06790d6ecce6c3d300c5f3d756e28720f1d2 Mon Sep 17 00:00:00 2001 From: Jeff Dafoe Date: Thu, 11 Jun 2026 12:17:09 -0400 Subject: [PATCH] ZBBS-WORK-395: harden the admin mermaid note-render path against XSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notes viewer's mermaid path was the one v-html sink fed from semi-trusted data (notes are authored by agents and the dream pipeline's LLM output) whose only sanitization was an implicit third-party default: mermaid 11.x's securityLevel defaulting to 'strict'. No app-level backstop, no CSP — a future 'loose' tweak or a mermaid sanitizer bypass (they have securityLevel-bypass CVEs in their history) would have been live stored XSS. Three coupled changes in notes.js: - Pin securityLevel: 'strict' explicitly so it can't silently regress. - htmlLabels: false (+ flowchart.htmlLabels) — labels render as native SVG instead of foreignObject+XHTML. Verified empirically against the repo's exact dists (mermaid 11.15.0 / dompurify 3.4.8, headless Firefox): with HTML labels on, the SVG sanitize profile destroys every flowchart label, and re-allowing foreignObject still loses its HTML children to DOMPurify's namespace rules. Pure-SVG output sanitizes losslessly (flowchart 124->124, sequence 64->64, adversarial 81->81 elements; payloads survive only as inert escaped label text; DOM scan shows no on* attrs / script / javascript: URLs). - App-level DOMPurify pass (SVG profiles) on mermaid's output before the v-html assignment, mirroring the markdown path's belt-and-suspenders. code_review 1 round: no blocking issues, ship as-is. Pre-existing non-blockers left untouched: the error-fallback
 construction
and the Date.now() render-id (collision-prone only sub-millisecond).
CSP for the admin app remains the ticket's deferred item 3.

Frontend-only; no migration. vite build:admin passes.

Co-Authored-By: Claude Fable 5 
---
 node/api/public/admin/notes.js | 28 +++++++++++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/node/api/public/admin/notes.js b/node/api/public/admin/notes.js
index 62dfe7fe..b0709138 100644
--- a/node/api/public/admin/notes.js
+++ b/node/api/public/admin/notes.js
@@ -8,6 +8,22 @@ import svgPanZoom from 'svg-pan-zoom';
 // Initialize mermaid — startOnLoad false since we render manually
 mermaid.initialize({
     startOnLoad: false,
+    // securityLevel 'strict' is mermaid 11.x's default, but pin it explicitly:
+    // it is the layer that DOMPurifies mermaid's own SVG output, and a future
+    // "loose" (the common tweak to enable click handlers / HTML labels) would
+    // silently turn the v-html sink below into stored XSS. Note content is
+    // semi-trusted — agents and the dream pipeline (LLM output) author it.
+    // (ZBBS-WORK-395)
+    securityLevel: 'strict',
+    // Render labels as native SVG  instead of  + HTML.
+    // This pairs with the app-level DOMPurify SVG pass at the render site:
+    // with htmlLabels on, flowchart labels are HTML-in-SVG, which the SVG
+    // sanitize profile strips (verified: every flowchart label vanished) and
+    // which DOMPurify's namespace rules won't cleanly re-allow. Pure-SVG
+    // output sanitizes losslessly — verified zero element loss on flowchart,
+    // sequence, and adversarial-payload diagrams. (ZBBS-WORK-395)
+    htmlLabels: false,
+    flowchart: { htmlLabels: false },
     theme: 'base',
     themeVariables: {
         background: '#1c1c30',
@@ -289,7 +305,17 @@ function useNotes({ api, showToast, showConfirm, onEvent }) {
                 // mermaid.render needs a unique ID per call
                 const id = 'mermaid-' + Date.now();
                 const { svg } = await mermaid.render(id, diagram);
-                renderedNoteContent.value = svg;
+                // App-level sanitize before the v-html sink, mirroring the
+                // markdown path below — mermaid's internal DOMPurify pass
+                // (securityLevel 'strict') is a single point of failure with
+                // bypass CVEs in its history. The SVG profiles pass mermaid's
+                // pure-SVG output through losslessly BECAUSE htmlLabels is
+                // off in the initialize above (with HTML labels they'd strip
+                // every flowchart label) — keep the two settings together.
+                // (ZBBS-WORK-395)
+                renderedNoteContent.value = DOMPurify.sanitize(svg, {
+                    USE_PROFILES: { svg: true, svgFilters: true },
+                });
                 // Wait for DOM update, then attach pan+zoom
                 await nextTick();
                 initPanZoom();