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 `<svg`. This does not remove malicious attributes/elements (e.g., event handlers, scriptable SVG payloads), enabling DOM-based XSS if a plugin asset is malicious or compromised.

Affected files: PluginIcon.tsx

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
This commit is contained in:
tuanaiseo
2026-04-12 06:17:10 +07:00
parent e2459cb0f8
commit f705f2555e

View File

@@ -10,6 +10,43 @@ type Props = {
// Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>();
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('<svg')) {
svgCache.set(url, text);
setSvg(text);
if (!text) return;
const sanitized = sanitizeSvg(text);
if (sanitized) {
svgCache.set(url, sanitized);
setSvg(sanitized);
}
})
.catch(() => {});
@@ -35,10 +74,6 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
if (!svg) return <span className={className} />;
return (
<span
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
);
}