mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-03 19:15:37 +08:00
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:
@@ -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 }} />
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user