diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts
index 1f2054656a..439eb4dcce 100644
--- a/client/src/utils/__tests__/svg.test.ts
+++ b/client/src/utils/__tests__/svg.test.ts
@@ -271,6 +271,16 @@ describe('sanitizeSvg', () => {
expect(clean).not.toContain('alert(1)');
});
+ it('does not let escaped markup in a stylesheet reintroduce elements', () => {
+ // Scrubbed CSS is set as a text node, so `\3c/style\3e\3cimage\3e` stays
+ // inert escaped text rather than becoming a real element.
+ const dirty =
+ '';
+ const clean = sanitizeSvg(dirty);
+ expect(clean.toLowerCase()).not.toContain(' {
const attrEsc = sanitizeSvg(
'',
diff --git a/packages/api/src/mcp/icons.spec.ts b/packages/api/src/mcp/icons.spec.ts
index 65091e181e..87bebe453f 100644
--- a/packages/api/src/mcp/icons.spec.ts
+++ b/packages/api/src/mcp/icons.spec.ts
@@ -131,6 +131,22 @@ describe('sanitizeMcpIconPath', () => {
expect(clean).not.toContain('alert(1)');
});
+ it('does not let escaped markup in a stylesheet reintroduce elements past the allowlist', () => {
+ // `\3c` = "<", `\3e` = ">": harmless text during the first pass, real markup
+ // once the CSS is un-escaped and spliced back — must be re-sanitized away.
+ const cases = [
+ '',
+ '',
+ '',
+ ];
+ for (const raw of cases) {
+ const clean = decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(raw)}`));
+ expect(clean).not.toContain('evil.example');
+ expect(clean.toLowerCase()).not.toContain(' {
for (const attr of [
'filter="url(https://evil.example/f.svg#f)"',
diff --git a/packages/api/src/mcp/icons.ts b/packages/api/src/mcp/icons.ts
index e495679a5c..b7eebc6c8c 100644
--- a/packages/api/src/mcp/icons.ts
+++ b/packages/api/src/mcp/icons.ts
@@ -362,7 +362,13 @@ export function sanitizeMcpIconPath(iconPath: string): string {
if (svg == null) {
return '';
}
- const clean = scrubStyleBlocks(sanitizeHtml(svg, SVG_SANITIZE_OPTIONS));
+ const sanitized = sanitizeHtml(svg, SVG_SANITIZE_OPTIONS);
+ const scrubbed = scrubStyleBlocks(sanitized);
+ /* `scrubStyleBlocks` splices un-escaped CSS back as raw markup, so an escaped
+ * sequence like `\3c/style\3e\3cimage/\3e` can reintroduce a real element past
+ * the allowlist. When a `