From f705f2555e0353f74b4f51509194adda1f107a74 Mon Sep 17 00:00:00 2001 From: tuanaiseo Date: Sun, 12 Apr 2026 06:17:10 +0700 Subject: [PATCH] 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 ` --- src/components/plugins/view/PluginIcon.tsx | 51 ++++++++++++++++++---- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/src/components/plugins/view/PluginIcon.tsx b/src/components/plugins/view/PluginIcon.tsx index fd59dbbc9..4999dc105 100644 --- a/src/components/plugins/view/PluginIcon.tsx +++ b/src/components/plugins/view/PluginIcon.tsx @@ -10,6 +10,43 @@ type Props = { // Module-level cache so repeated renders don't re-fetch const svgCache = new Map(); +function sanitizeSvg(svgText: string): string | null { + try { + const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); + const root = doc.documentElement; + if (!root || root.nodeName.toLowerCase() !== 'svg') return null; + + doc + .querySelectorAll('script,foreignObject,iframe,object,embed,link,meta,style') + .forEach((el) => el.remove()); + + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + const elements: Element[] = [root]; + while (walker.nextNode()) { + elements.push(walker.currentNode as Element); + } + + elements.forEach((el) => { + Array.from(el.attributes).forEach((attr) => { + const name = attr.name.toLowerCase(); + const value = attr.value.trim().toLowerCase(); + if ( + name.startsWith('on') || + name === 'href' || + name === 'xlink:href' || + value.startsWith('javascript:') + ) { + el.removeAttribute(attr.name); + } + }); + }); + + return new XMLSerializer().serializeToString(root); + } catch { + return null; + } +} + export default function PluginIcon({ pluginName, iconFile, className }: Props) { const url = iconFile ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` @@ -24,9 +61,11 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) { return r.text(); }) .then((text) => { - if (text && text.trimStart().startsWith(' {}); @@ -35,10 +74,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) { if (!svg) return ; return ( - + ); }