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 <noreply@anthropic.com>
This commit is contained in:
Classic298
2026-05-31 23:49:43 +02:00
committed by GitHub
parent 690d6e5eb1
commit bf6325ff33
+40 -1
View File
@@ -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 {