fix: resolve CSS escapes before stripping external SVG references

A literal url(/@import matcher missed CSS-escaped references: a value
like style="fill:u\72l(https://attacker/x)" or an escaped @import
survived sanitization because the browser un-escapes \72 to r at
tokenization time while the regex never saw a url(...). The external
reference was then persisted and served to other clients.

Un-escape CSS the way a browser does (single pass over \hex and
\char escapes) before the url()/@import matchers run, on both the client
and server sanitizers. Also scrub the multi-declaration style attribute
in place instead of dropping it, so co-located local paint rules survive
while escaped external references are neutralized.
This commit is contained in:
Marco Beretta 2026-07-04 01:17:09 +02:00
parent a1f768c083
commit 3a94d5d9ff
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 136 additions and 14 deletions

View file

@ -271,6 +271,33 @@ describe('sanitizeSvg', () => {
expect(clean).not.toContain('alert(1)');
});
it('strips CSS-escaped external url() from style attributes and stylesheets', () => {
const attrEsc = sanitizeSvg(
'<svg><rect style="fill:u\\72l(https://evil.example/x)" width="10" height="10" /></svg>',
);
expect(attrEsc).not.toContain('evil.example');
const styleEsc = sanitizeSvg(
'<svg><style>.a{fill:u\\72l(https://evil.example/b)}.b{fill:url(#g)}</style><rect class="a" /></svg>',
);
expect(styleEsc).not.toContain('evil.example');
expect(styleEsc).toContain('url(#g)');
});
it('strips CSS-escaped @import from internal stylesheets', () => {
const clean = sanitizeSvg(
'<svg><style>\\40import "https://evil.example/x.css";</style><rect /></svg>',
);
expect(clean).not.toContain('evil.example');
});
it('keeps co-located local declarations when scrubbing an escaped external ref', () => {
const clean = sanitizeSvg(
'<svg><rect style="fill:u\\72l(https://evil.example/x);stroke:#000" width="10" height="10" /></svg>',
);
expect(clean).not.toContain('evil.example');
expect(clean).toContain('stroke:#000');
});
it('drops href-smuggling animation elements', () => {
const dirty = '<svg><a><animate attributeName="href" values="javascript:alert(1)" /></a></svg>';
const clean = sanitizeSvg(dirty);

View file

@ -117,12 +117,39 @@ export function detectMonochrome(src: string): Promise<boolean> {
* optional quote and the target so non-fragment references can be rejected. */
const CSS_URL_REFERENCE = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
/** Matches a single CSS escape: `\` + up to six hex digits (with an optional
* trailing whitespace the browser consumes), or `\` + any other character. */
const CSS_ESCAPE = /\\(?:([0-9a-fA-F]{1,6})\s?|(.))/g;
/**
* Resolves CSS escape sequences the way a browser does at tokenization time, so
* an obfuscated reference like `u\72l(…)` or `\40import` is seen as the `url()`
* / `@import` it becomes before the literal matchers run. Single pass, matching
* the browser: `u\5c72l` stays a literal backslash and is not a `url` token.
*/
export function unescapeCss(value: string): string {
if (!value.includes('\\')) {
return value;
}
return value.replace(CSS_ESCAPE, (_match, hex?: string, char?: string) => {
if (hex === undefined) {
return char ?? '';
}
const code = Number.parseInt(hex, 16);
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
return '<27>';
}
return String.fromCodePoint(code);
});
}
/** True when a value carries a `url(...)` reference that is not a same-document
* fragment, e.g. `url(https://…)`, `url(//…)`, `url(data:…)`, or `url(x.svg#id)`. */
export function hasExternalUrlReference(value: string): boolean {
CSS_URL_REFERENCE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CSS_URL_REFERENCE.exec(value)) !== null) {
const decoded = unescapeCss(value);
while ((match = CSS_URL_REFERENCE.exec(decoded)) !== null) {
if (!match[2].trim().startsWith('#')) {
return true;
}
@ -131,13 +158,14 @@ export function hasExternalUrlReference(value: string): boolean {
}
/**
* Neutralizes external references inside an internal `<style>` block (used by
* exporter SVGs that store multi-color paint in class rules) while keeping the
* local rules intact: strips comments and `@import` at-rules, and rewrites any
* non-fragment `url(...)` to `none`.
* Neutralizes external references inside a `<style>` block or inline `style`
* (used by exporter SVGs that store multi-color paint in class rules) while
* keeping local rules intact. Resolves CSS escapes first so an obfuscated
* reference cannot hide, then strips comments and `@import` at-rules and
* rewrites any non-fragment `url(...)` to `none`.
*/
export function sanitizeCssText(css: string): string {
return css
return unescapeCss(css)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/@import[^;]*;?/gi, '')
.replace(CSS_URL_REFERENCE, (full, _quote, target) =>
@ -173,7 +201,9 @@ function getSvgPurifier(): ReturnType<typeof DOMPurify> {
}
}
for (const attr of Array.from(node.attributes)) {
if (hasExternalUrlReference(attr.value)) {
if (attr.name === 'style') {
node.setAttribute('style', sanitizeCssText(attr.value));
} else if (hasExternalUrlReference(attr.value)) {
node.removeAttribute(attr.name);
}
}

View file

@ -152,6 +152,36 @@ describe('sanitizeMcpIconPath', () => {
expect(clean).toContain('filter="url(#f)"');
});
it('strips CSS-escaped external url() from style attributes and stylesheets', () => {
const attr =
'<svg><rect style="fill:u\\72l(https://evil.example/x)" width="10" height="10"/></svg>';
expect(
decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(attr)}`)),
).not.toContain('evil.example');
const block =
'<svg><style>.a{fill:u\\72l(https://evil.example/b)}.b{fill:url(#g)}</style><rect class="a"/></svg>';
const cleanBlock = decode(
sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(block)}`),
);
expect(cleanBlock).not.toContain('evil.example');
expect(cleanBlock).toContain('url(#g)');
});
it('strips CSS-escaped @import from internal stylesheets', () => {
const raw = '<svg><style>\\40import "https://evil.example/x.css";</style><rect/></svg>';
expect(
decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(raw)}`)),
).not.toContain('evil.example');
});
it('keeps co-located local declarations when scrubbing an escaped external ref', () => {
const raw =
'<svg><rect style="fill:u\\72l(https://evil.example/x);stroke:#000" width="10" height="10"/></svg>';
const clean = decode(sanitizeMcpIconPath(`data:image/svg+xml,${encodeURIComponent(raw)}`));
expect(clean).not.toContain('evil.example');
expect(clean).toContain('stroke:#000');
});
it('preserves case-sensitive SVG names and multi-color paint', () => {
const raw =
'<svg viewBox="0 0 24 24"><linearGradient id="g"><stop offset="0" stop-color="#f00"/></linearGradient><path d="M0 0h24v24H0z" fill="url(#g)"/></svg>';

View file

@ -185,12 +185,39 @@ const ALLOWED_SVG_ATTRS = [
* the optional quote and the target so non-fragment references can be rejected. */
const CSS_URL_REFERENCE = /url\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
/** Matches a single CSS escape: `\` + up to six hex digits (with an optional
* trailing whitespace the browser consumes), or `\` + any other character. */
const CSS_ESCAPE = /\\(?:([0-9a-fA-F]{1,6})\s?|(.))/g;
/**
* Resolves CSS escape sequences the way a browser does at tokenization time, so
* an obfuscated reference like `u\72l(…)` or `\40import` is seen as the `url()`
* / `@import` it becomes before the literal matchers run. Single pass, matching
* the browser: `u\5c72l` stays a literal backslash and is not a `url` token.
*/
function unescapeCss(value: string): string {
if (!value.includes('\\')) {
return value;
}
return value.replace(CSS_ESCAPE, (_match, hex: string | undefined, char: string | undefined) => {
if (hex === undefined) {
return char ?? '';
}
const code = Number.parseInt(hex, 16);
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
return '<27>';
}
return String.fromCodePoint(code);
});
}
/** True when a value carries a `url(...)` reference that is not a same-document
* fragment, e.g. `url(https://…)`, `url(//…)`, `url(data:…)`, or `url(x.svg#id)`. */
function hasExternalUrlReference(value: string): boolean {
CSS_URL_REFERENCE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CSS_URL_REFERENCE.exec(value)) !== null) {
const decoded = unescapeCss(value);
while ((match = CSS_URL_REFERENCE.exec(decoded)) !== null) {
if (!match[2].trim().startsWith('#')) {
return true;
}
@ -198,10 +225,12 @@ function hasExternalUrlReference(value: string): boolean {
return false;
}
/** Strips comments and `@import` at-rules and rewrites external `url(...)` to
* `none`, keeping only local paint rules from an internal stylesheet. */
/** Resolves CSS escapes, then strips comments and `@import` at-rules and rewrites
* external `url(...)` to `none`, keeping only local paint rules from a stylesheet
* or inline `style`. Escapes are resolved first so an obfuscated reference cannot
* hide from the matchers. */
function sanitizeCssText(css: string): string {
return css
return unescapeCss(css)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/@import[^;]*;?/gi, '')
.replace(CSS_URL_REFERENCE, (full, _quote, target) =>
@ -220,9 +249,11 @@ function scrubStyleBlocks(svg: string): string {
/**
* Drops references that leave the document: any `href`/`xlink:href` that is not a
* same-document fragment, and any attribute (`filter`, `fill`, `mask`,
* `clip-path`, `style`, ) carrying a non-fragment `url(...)`. Mirrors the
* client-side `sanitizeSvg` rule so stored icons cannot pull external resources.
* same-document fragment, and any single-value presentation attribute (`filter`,
* `fill`, `mask`, `clip-path`, ) carrying a non-fragment `url(...)`. The
* multi-declaration `style` attribute is scrubbed in place instead so its local
* paint rules survive. All checks resolve CSS escapes first (see `unescapeCss`),
* mirroring the client-side `sanitizeSvg` rule so stored icons cannot fetch.
*/
function keepLocalReferences(tagName: string, attribs: sanitizeHtml.Attributes): sanitizeHtml.Tag {
for (const [name, value] of Object.entries(attribs)) {
@ -232,6 +263,10 @@ function keepLocalReferences(tagName: string, attribs: sanitizeHtml.Attributes):
}
continue;
}
if (name === 'style') {
attribs[name] = sanitizeCssText(value);
continue;
}
if (hasExternalUrlReference(value)) {
delete attribs[name];
}