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);