mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
a1f768c083
commit
3a94d5d9ff
4 changed files with 136 additions and 14 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue