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:
1 parent
690d6e5eb1
commit
bf6325ff33
1 file changed
+40
-1
+40
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import sha256 from 'js-sha256';
|
import sha256 from 'js-sha256';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -1911,6 +1912,44 @@ const cleanupMermaidTempElements = (id: string) => {
|
|||||||
document.getElementById(`i${id}`)?.remove();
|
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 (
|
export const renderMermaidDiagram = async (
|
||||||
mermaid: typeof import('mermaid').default,
|
mermaid: typeof import('mermaid').default,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -1921,7 +1960,7 @@ export const renderMermaidDiagram = async (
|
|||||||
const parseResult = await mermaid.parse(code, { suppressErrors: false });
|
const parseResult = await mermaid.parse(code, { suppressErrors: false });
|
||||||
if (parseResult) {
|
if (parseResult) {
|
||||||
const { svg } = await mermaid.render(id, code);
|
const { svg } = await mermaid.render(id, code);
|
||||||
return svg;
|
return sanitizeSvg(svg);
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in new issue
Block a user