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 `