mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-02 02:12:25 +00:00
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.
This commit is contained in:
parent
ee3ece1e2b
commit
46ea826e01
3 changed files with 153 additions and 89 deletions
|
|
@ -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 @@
|
|||
</div>
|
||||
{:else if isSvg && !showRaw && fileContent}
|
||||
<div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3">
|
||||
{@html DOMPurify.sanitize(fileContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['use']
|
||||
})}
|
||||
{@html sanitizeSvg(fileContent)}
|
||||
</div>
|
||||
{:else if isCode && !showRaw}
|
||||
<div class="absolute inset-0">
|
||||
|
|
|
|||
|
|
@ -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 <svg>, <defs>, <filter>, 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)}
|
||||
</PanzoomContainer>
|
||||
|
||||
{#if content}
|
||||
|
|
|
|||
|
|
@ -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(`<style>${css}</style>`, '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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue