From 46ea826e012a63a60c5955b2c69c74a0a0eb9c56 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:15:28 +0200 Subject: [PATCH] refac: keep rendered diagrams and SVG on same-origin resources (#30271) Mermaid diagrams and the shared SVG sanitizer now accept only same-origin and data: references. Image URLs, class styles and directive config are checked on the parsed diagram before it renders, and the sanitizer drops attribute values and stylesheet rules that point at another origin. use elements keep local #id references only. SVGPanZoom and the SVG file preview call the shared sanitizer instead of keeping their own configs, so SVG artifacts and uploaded SVG files follow the same rule. Uploaded SVGs that reference external sprites now render those parts blank, which is the point of the change. A diagram that references an external resource now reports an error instead of rendering, and an SVG artifact keeps everything except the rules that reference one. --- .../chat/FileNav/FilePreview.svelte | 7 +- src/lib/components/common/SVGPanZoom.svelte | 44 +--- src/lib/utils/index.ts | 191 ++++++++++++++---- 3 files changed, 153 insertions(+), 89 deletions(-) diff --git a/src/lib/components/chat/FileNav/FilePreview.svelte b/src/lib/components/chat/FileNav/FilePreview.svelte index 51ec52e7be..d6a686a159 100644 --- a/src/lib/components/chat/FileNav/FilePreview.svelte +++ b/src/lib/components/chat/FileNav/FilePreview.svelte @@ -5,7 +5,7 @@ import { settings, config } from '$lib/stores'; import { injectCsp } from '$lib/utils/csp'; import { isCodeFile } from '$lib/utils/codeHighlight'; - import { initMermaid, renderMermaidDiagram } from '$lib/utils'; + import { initMermaid, renderMermaidDiagram, sanitizeSvg } from '$lib/utils'; import Spinner from '../../common/Spinner.svelte'; import PdfPagesPreview from '../../common/PdfPagesPreview.svelte'; import PanzoomContainer from '../../common/PanzoomContainer.svelte'; @@ -478,10 +478,7 @@ {:else if isSvg && !showRaw && fileContent}
- {@html DOMPurify.sanitize(fileContent, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ['use'] - })} + {@html sanitizeSvg(fileContent)}
{:else if isCode && !showRaw}
diff --git a/src/lib/components/common/SVGPanZoom.svelte b/src/lib/components/common/SVGPanZoom.svelte index 307ddda21e..e85adc6716 100644 --- a/src/lib/components/common/SVGPanZoom.svelte +++ b/src/lib/components/common/SVGPanZoom.svelte @@ -4,12 +4,10 @@ import { toast } from 'svelte-sonner'; - import DOMPurify from 'dompurify'; - import { getContext } from 'svelte'; const i18n = getContext('i18n'); - import { copyToClipboard } from '$lib/utils'; + import { copyToClipboard, sanitizeSvg } from '$lib/utils'; import PanzoomContainer from './PanzoomContainer.svelte'; import Tooltip from './Tooltip.svelte'; @@ -37,45 +35,7 @@ bind:this={panzoomRef} className="flex h-full max-h-full justify-center items-center" > - {@html DOMPurify.sanitize(svg, { - USE_PROFILES: { svg: true, svgFilters: true }, // allow , , , etc. - WHOLE_DOCUMENT: false, - ADD_TAGS: ['style', 'foreignObject'], // include foreignObject if using HTML labels - ADD_ATTR: [ - 'class', - 'style', - 'id', - 'data-*', - 'viewBox', - 'preserveAspectRatio', - // markers / arrows - 'markerWidth', - 'markerHeight', - 'markerUnits', - 'refX', - 'refY', - 'orient', - // hrefs (for gradients, markers, etc.) - 'href', - 'xlink:href', - // text positioning - 'dominant-baseline', - 'text-anchor', - // pattern / clip / mask units - 'clipPathUnits', - 'filterUnits', - 'patternUnits', - 'patternContentUnits', - 'maskUnits', - // a11y niceties - 'role', - 'aria-label', - 'aria-labelledby', - 'aria-hidden', - 'tabindex' - ], - SANITIZE_DOM: true - })} + {@html sanitizeSvg(svg)} {#if content} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index fdf3bf7442..dd25bb5dc8 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,7 +1,7 @@ import type { Writable } from 'svelte/store'; import { v4 as uuidv4 } from 'uuid'; import sha256 from 'js-sha256'; -import DOMPurify from 'dompurify'; +import DOMPurify, { type UponSanitizeAttributeHookEvent } from 'dompurify'; import { WEBUI_BASE_URL } from '$lib/constants'; import type { FileNavOpenRequest } from '$lib/stores'; import { normalizeDocumentTargetPage } from '$lib/utils/documentPreview'; @@ -2133,7 +2133,8 @@ export const initMermaid = async () => { startOnLoad: false, // Should be false when using render API theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default', securityLevel: 'loose', - htmlLabels: false + htmlLabels: false, + secure: ['htmlLabels'] }); return mermaid; }; @@ -2148,43 +2149,133 @@ const cleanupMermaidTempElements = (id: string) => { document.getElementById(`i${id}`)?.remove(); }; +const isLocalResourceUrl = (url: string): boolean => { + try { + const resolved = new URL(url, document.baseURI); + return resolved.protocol === 'data:' || resolved.origin === location.origin; + } catch { + return false; + } +}; + +// Escapes and image-set() read as a plain url() to a browser, so match the parsed CSS, not the text. +const CSS_URL_SYNTAX = /url\(|image-set\(|@import|\\/i; + +const cssRules = (css: string): string[] => { + const parsed = new DOMParser().parseFromString(``, 'text/html'); + return [...(parsed.querySelector('style')?.sheet?.cssRules ?? [])].map((rule) => rule.cssText); +}; + +const referencesExternalCss = (css: string): boolean => { + if (!CSS_URL_SYNTAX.test(css)) { + return false; + } + + // A value arrives as a stylesheet, a declaration list or a bare presentation value. + return [css, `a{${css}}`, `a{background-image:${css}}`].some((stylesheet) => { + const urls = cssRules(stylesheet) + .join('') + .matchAll(/url\(["']?([^"')]*)/g); + return [...urls].some(([, url]) => !isLocalResourceUrl(url)); + }); +}; + +const dropExternalResourceRefs = (node: Node, data: UponSanitizeAttributeHookEvent) => { + const tagName = node.nodeName.toLowerCase(); + const isImageRef = + (tagName === 'image' || tagName === 'feimage') && + (data.attrName === 'href' || data.attrName === 'xlink:href'); + + if (isImageRef && !isLocalResourceUrl(data.attrValue)) { + data.keepAttr = false; + } + if (!isImageRef && referencesExternalCss(data.attrValue)) { + data.keepAttr = false; + } +}; + +// use hrefs point at defs inside the same document (#id) or fetch another file, keep only the former. +const dropExternalUseRefs = (node: Node, data: UponSanitizeAttributeHookEvent) => { + if (node.nodeName.toLowerCase() !== 'use') { + return; + } + if (data.attrName !== 'href' && data.attrName !== 'xlink:href') { + return; + } + if (!data.attrValue.trim().startsWith('#')) { + data.keepAttr = false; + } +}; + +const dropExternalStyleRules = (node: Node) => { + if (node.nodeName.toLowerCase() !== 'style') { + return; + } + + const css = node.textContent ?? ''; + if (!referencesExternalCss(css)) { + return; + } + + node.textContent = cssRules(css) + .filter((rule) => !referencesExternalCss(rule)) + .join(''); +}; + // 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 sanitizeSvg = (svg: string): string => { + DOMPurify.addHook('uponSanitizeAttribute', dropExternalResourceRefs); + DOMPurify.addHook('uponSanitizeElement', dropExternalStyleRules); + DOMPurify.addHook('uponSanitizeAttribute', dropExternalUseRefs); + try { + return DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + WHOLE_DOCUMENT: false, + ADD_TAGS: ['style', 'foreignObject', 'use'], + 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 + }); + } finally { + DOMPurify.removeHook('uponSanitizeAttribute', dropExternalResourceRefs); + DOMPurify.removeHook('uponSanitizeElement', dropExternalStyleRules); + DOMPurify.removeHook('uponSanitizeAttribute', dropExternalUseRefs); + } +}; + +const configStrings = (value: any): string[] => { + if (typeof value === 'string') { + return [value]; + } + return value && typeof value === 'object' ? Object.values(value).flatMap(configStrings) : []; +}; export const renderMermaidDiagram = async ( mermaid: typeof import('mermaid').default, @@ -2193,12 +2284,28 @@ export const renderMermaidDiagram = async ( ) => { const id = renderId ?? `mermaid-${uuidv4()}`; try { - const parseResult = await mermaid.parse(code, { suppressErrors: false }); - if (parseResult) { - const { svg } = await mermaid.render(id, code); - return sanitizeSvg(svg); + // Mermaid renders into a live document, so its own fetches have to be ruled out beforehand. + const { config } = (await mermaid.parse(code, { suppressErrors: false })) as any; + const diagram: any = await mermaid.mermaidAPI.getDiagramFromText(code); + const imageUrls = [ + ...[...(diagram.db.getVertices?.() ?? []).values()].map((vertex: any) => vertex.img), + ...[...(diagram.db.getActors?.() ?? []).values()].map((actor: any) => actor.properties?.icon) + ]; + const classStyles = [...(diagram.db.getClasses?.() ?? []).values()].flatMap((classDef: any) => [ + ...(classDef.styles ?? []), + ...(classDef.textStyles ?? []) + ]); + + if ( + imageUrls.some((url) => url && !isLocalResourceUrl(url)) || + classStyles.some(referencesExternalCss) || + configStrings(config).some(referencesExternalCss) + ) { + throw new Error('External resource loading is disabled for rendered diagrams'); } - return ''; + + const { svg } = await mermaid.render(id, code); + return sanitizeSvg(svg); } finally { // Mermaid can leave temporary d*/i* wrappers on error paths. cleanupMermaidTempElements(id);