From bf6325ff337aa131495a87a15333dc1773f2ff1f Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sun, 31 May 2026 23:49:43 +0200 Subject: [PATCH] fix: sanitize mermaid SVG output to prevent stored XSS in file preview (#25219) renderMermaidDiagram returned raw mermaid SVG, which FilePreview.svelte injects via wrapper.innerHTML = svg. Mermaid runs with securityLevel: 'loose', so it neither sanitizes click hrefs (formatUrl skips sanitizeUrl) nor DOMPurifies its output; a .md file with a click X href "javascript:..." directive (or an HTML-label payload) therefore executes script in the app origin when previewed. The chat path was already safe because SVGPanZoom DOMPurifies before rendering; file preview was not. Sanitize at the source: renderMermaidDiagram now returns DOMPurify-cleaned SVG via a shared sanitizeSvg helper (same policy as SVGPanZoom), so every consumer including the FilePreview innerHTML sink receives safe output. Co-authored-by: Claude Opus 4.8 --- src/lib/utils/index.ts | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index b4009f198a..adfc979a7a 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,6 +1,7 @@ import type { Writable } from 'svelte/store'; import { v4 as uuidv4 } from 'uuid'; import sha256 from 'js-sha256'; +import DOMPurify from 'dompurify'; import { WEBUI_BASE_URL } from '$lib/constants'; import dayjs from 'dayjs'; @@ -1911,6 +1912,44 @@ const cleanupMermaidTempElements = (id: string) => { document.getElementById(`i${id}`)?.remove(); }; +// Mermaid runs with securityLevel:'loose', which emits unsanitized SVG (raw javascript: hrefs, +// HTML labels); strip active content before it reaches any innerHTML/{@html} sink. +export const sanitizeSvg = (svg: string): string => + DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + WHOLE_DOCUMENT: false, + ADD_TAGS: ['style', 'foreignObject'], + ADD_ATTR: [ + 'class', + 'style', + 'id', + 'data-*', + 'viewBox', + 'preserveAspectRatio', + 'markerWidth', + 'markerHeight', + 'markerUnits', + 'refX', + 'refY', + 'orient', + 'href', + 'xlink:href', + 'dominant-baseline', + 'text-anchor', + 'clipPathUnits', + 'filterUnits', + 'patternUnits', + 'patternContentUnits', + 'maskUnits', + 'role', + 'aria-label', + 'aria-labelledby', + 'aria-hidden', + 'tabindex' + ], + SANITIZE_DOM: true + }); + export const renderMermaidDiagram = async ( mermaid: typeof import('mermaid').default, code: string, @@ -1921,7 +1960,7 @@ export const renderMermaidDiagram = async ( const parseResult = await mermaid.parse(code, { suppressErrors: false }); if (parseResult) { const { svg } = await mermaid.render(id, code); - return svg; + return sanitizeSvg(svg); } return ''; } finally {