mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-04 11:45:38 +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> * fix: sanitize plugin svg icons --------- Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com> Co-authored-by: tuanaiseo <tuanaiseo@gmail.com> Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
87 lines
2.0 KiB
TypeScript
87 lines
2.0 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import DOMPurify from 'dompurify';
|
|
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
|
|
type Props = {
|
|
pluginName: string;
|
|
iconFile: string;
|
|
className?: string;
|
|
};
|
|
|
|
// Module-level cache so repeated renders don't re-fetch
|
|
const svgCache = new Map<string, string>();
|
|
|
|
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)}`
|
|
: '';
|
|
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
|
|
|
|
useEffect(() => {
|
|
if (!url || svgCache.has(url)) return;
|
|
authenticatedFetch(url)
|
|
.then((r) => {
|
|
if (!r.ok) return;
|
|
return r.text();
|
|
})
|
|
.then((text) => {
|
|
if (!text) return;
|
|
const sanitized = sanitizeSvg(text);
|
|
if (sanitized) {
|
|
svgCache.set(url, sanitized);
|
|
setSvg(sanitized);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, [url]);
|
|
|
|
if (!svg) return <span className={className} />;
|
|
|
|
return (
|
|
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} />
|
|
);
|
|
}
|