From 9c7718e854169f049d2b1f021c052dcdfad015f5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:06:43 +0200 Subject: [PATCH] fix: decode XML entities and parse quoted URLs in SVG CSS scrubbing Two more bypasses of the CSS reference scrubber, both live once the stored image/svg+xml is parsed by a viewer: - A markup breakout, which the re-sanitize pass then strips. --- client/src/utils/__tests__/svg.test.ts | 21 ++++++++ client/src/utils/svg.ts | 68 ++++++++++++++++++----- packages/api/src/mcp/icons.spec.ts | 32 +++++++++++ packages/api/src/mcp/icons.ts | 74 +++++++++++++++++++++----- 4 files changed, 170 insertions(+), 25 deletions(-) diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts index 439eb4dcce..00c9bc525b 100644 --- a/client/src/utils/__tests__/svg.test.ts +++ b/client/src/utils/__tests__/svg.test.ts @@ -308,6 +308,27 @@ describe('sanitizeSvg', () => { expect(clean).toContain('stroke:#000'); }); + it('strips XML-entity-encoded @import from internal stylesheets', () => { + for (const enc of ['@import', '@import', '@IMPORT']) { + const clean = sanitizeSvg( + ``, + ); + expect(clean).not.toContain('evil.example'); + } + }); + + it('strips a quoted CSS url() whose path contains a right parenthesis', () => { + const block = sanitizeSvg( + '', + ); + expect(block).not.toContain('evil.example'); + expect(block).toContain('url(#g)'); + const attr = sanitizeSvg( + '', + ); + expect(attr).not.toContain('evil.example'); + }); + it('drops href-smuggling animation elements', () => { const dirty = ''; const clean = sanitizeSvg(dirty); diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts index f7f6a01b4c..fae614ef0d 100644 --- a/client/src/utils/svg.ts +++ b/client/src/utils/svg.ts @@ -113,9 +113,45 @@ export function detectMonochrome(src: string): Promise { }); } -/** Matches every `url(...)` reference in a CSS/presentation value, capturing the - * optional quote and the target so non-fragment references can be rejected. */ -const CSS_URL_REFERENCE = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi; +/** Matches every `url(...)` reference in a CSS/presentation value. A quoted + * target may contain `)` (capture groups 1/2), an unquoted one may not (group 3), + * so the target is `match[1] ?? match[2] ?? match[3]`. */ +const CSS_URL_REFERENCE = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]*))\s*\)/gi; + +/** XML predefined entities — the only named references a `data:image/svg+xml` + * document (parsed as XML) decodes; unknown named entities make it fail to parse. */ +const XML_NAMED_ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +/** + * Decodes the XML character references a browser resolves when it parses the + * stored `image/svg+xml` document, so `@import` / `@import` are seen as + * `@import` before the CSS matchers run (SVG ``; + expect( + decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(raw)}`)), + ).not.toContain('evil.example'); + } + }); + + it('strips markup smuggled through XML-entity-encoded ', () => { + const raw = + ''; + const clean = decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(raw)}`)); + expect(clean).not.toContain('evil.example'); + expect(clean.toLowerCase()).not.toContain(' { + const block = + ''; + const cleanBlock = decode( + sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(block)}`), + ); + expect(cleanBlock).not.toContain('evil.example'); + expect(cleanBlock).toContain('url(#g)'); + const attr = + ''; + expect( + decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(attr)}`)), + ).not.toContain('evil.example'); + }); + it('preserves case-sensitive SVG names and multi-color paint', () => { const raw = ''; diff --git a/packages/api/src/mcp/icons.ts b/packages/api/src/mcp/icons.ts index eeb66aa02f..4078892430 100644 --- a/packages/api/src/mcp/icons.ts +++ b/packages/api/src/mcp/icons.ts @@ -181,9 +181,50 @@ const ALLOWED_SVG_ATTRS = [ 'tableValues', ]; -/** Matches every `url(...)` reference in a presentation/style value, capturing - * the optional quote and the target so non-fragment references can be rejected. */ -const CSS_URL_REFERENCE = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi; +/** Matches every `url(...)` reference in a presentation/style value. A quoted + * target may contain `)` (capture groups 1/2), an unquoted one may not (group 3), + * so the target is `match[1] ?? match[2] ?? match[3]`. */ +const CSS_URL_REFERENCE = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")]*))\s*\)/gi; + +/** The target of a `CSS_URL_REFERENCE` match, from whichever quoting group hit. */ +function urlTarget(match: RegExpExecArray | RegExpMatchArray): string { + return (match[1] ?? match[2] ?? match[3] ?? '').trim(); +} + +/** XML predefined entities — the only named references a `data:image/svg+xml` + * document (parsed as XML) decodes; unknown named entities make it fail to parse. */ +const XML_NAMED_ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}; + +/** + * Decodes the XML character references a browser resolves when it parses the + * stored `image/svg+xml` document, so `@import` / `@import` are seen as + * `@import` before the CSS matchers run (SVG `