From d9e9df183f462c88c3b60975eb8254faa9168717 Mon Sep 17 00:00:00 2001 From: Haile <118998054+blackmammoth@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:24:38 +0300 Subject: [PATCH] fix: plugin svg icon sanitization (#817) * fix(security)(components): unsanitized svg content injected via `dangerouslys The plugin icon renderer fetches SVG text from `/api/plugins/.../assets/...` and injects it directly into the DOM using `dangerouslySetInnerHTML` after only checking that the payload starts with ` * fix: sanitize plugin svg icons --------- Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com> Co-authored-by: tuanaiseo Co-authored-by: Simos Mikelatos --- package-lock.json | 17 +++++++ package.json | 1 + src/components/plugins/view/PluginIcon.tsx | 58 +++++++++++++++++++--- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index f1937b10..0ef68958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "cmdk": "^1.1.1", "cors": "^2.8.5", "cross-spawn": "^7.0.3", + "dompurify": "^3.4.7", "express": "^4.18.2", "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", @@ -4580,6 +4581,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -7485,6 +7493,15 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", diff --git a/package.json b/package.json index fc6b7d75..1cd81aca 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "cmdk": "^1.1.1", "cors": "^2.8.5", "cross-spawn": "^7.0.3", + "dompurify": "^3.4.7", "express": "^4.18.2", "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", diff --git a/src/components/plugins/view/PluginIcon.tsx b/src/components/plugins/view/PluginIcon.tsx index fd59dbbc..ce8c90fc 100644 --- a/src/components/plugins/view/PluginIcon.tsx +++ b/src/components/plugins/view/PluginIcon.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import DOMPurify from 'dompurify'; + import { authenticatedFetch } from '../../../utils/api'; type Props = { @@ -10,6 +12,48 @@ type Props = { // Module-level cache so repeated renders don't re-fetch const svgCache = new Map(); +const FORBIDDEN_SVG_TAGS = [ + 'script', + 'foreignObject', + 'iframe', + 'object', + 'embed', + 'link', + 'meta', + 'style', + 'animate', + 'set', + 'animateTransform', + 'animateMotion', +]; + +const FORBIDDEN_SVG_ATTRS = [ + 'href', + 'xlink:href', + 'src', + 'style', +]; + +function sanitizeSvg(svgText: string): string | null { + const sanitized = DOMPurify.sanitize(svgText, { + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: FORBIDDEN_SVG_TAGS, + FORBID_ATTR: FORBIDDEN_SVG_ATTRS, + }); + + if (!sanitized) return null; + + try { + const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml'); + const root = doc.documentElement; + if (!root || root.nodeName.toLowerCase() !== 'svg') return null; + if (doc.querySelector('parsererror')) return null; + return sanitized; + } catch { + return null; + } +} + export default function PluginIcon({ pluginName, iconFile, className }: Props) { const url = iconFile ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` @@ -24,9 +68,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) { return r.text(); }) .then((text) => { - if (text && text.trimStart().startsWith(' {}); @@ -35,10 +81,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) { if (!svg) return ; return ( - + ); }