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 `