diff --git a/client/src/components/ui/CustomIcon.tsx b/client/src/components/ui/CustomIcon.tsx index 1de97033f9..a1489b4c70 100644 --- a/client/src/components/ui/CustomIcon.tsx +++ b/client/src/components/ui/CustomIcon.tsx @@ -33,7 +33,7 @@ export default function CustomIcon({ const decorative = alt === ''; if (shouldTint) { - const maskUrl = `url("${src.replace(/"/g, '%22')}")`; + const maskUrl = `url("${src.replace(/["\\\n\r\f]/g, encodeURIComponent)}")`; return ( { expect(span?.style.maskImage).toBe('url("/a%22.svg")'); }); + it('escapes backslashes and newlines in the mask URL', () => { + const { container } = render(); + + const span = container.querySelector('span'); + expect(span?.style.maskImage).toBe('url("/a%5Cb%0Ac.svg")'); + }); + it('does not render a probe image on the tinted path without an onError handler', () => { const { container } = render(); diff --git a/client/src/hooks/useAdaptiveIcon.ts b/client/src/hooks/useAdaptiveIcon.ts index ca68d50b82..acd009d956 100644 --- a/client/src/hooks/useAdaptiveIcon.ts +++ b/client/src/hooks/useAdaptiveIcon.ts @@ -63,11 +63,12 @@ export default function useAdaptiveIcon( monochrome: cachedVerdict(key), })); - /** Reset synchronously when the source changes so a verdict resolved for a - * previous icon never tints the new one; seed from cache when available. */ - if (state.key !== key) { - setState({ key, monochrome: cachedVerdict(key) }); - } + useEffect(() => { + setState((prev) => { + const monochrome = cachedVerdict(key); + return prev.key === key && prev.monochrome === monochrome ? prev : { key, monochrome }; + }); + }, [key]); useEffect(() => { if (key == null) { diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts index 00c9bc525b..02d3cac939 100644 --- a/client/src/utils/__tests__/svg.test.ts +++ b/client/src/utils/__tests__/svg.test.ts @@ -214,19 +214,6 @@ describe('sanitizeSvg', () => { expect(clean).not.toContain('evil.example'); }); - it('strips external url() references from presentation and style attributes', () => { - for (const attr of [ - 'filter="url(https://evil.example/f.svg#f)"', - 'fill="url(https://evil.example/p)"', - 'style="fill:url(//evil.example/p)"', - 'clip-path="url(data:image/svg+xml,evil)"', - ]) { - const clean = sanitizeSvg(``); - expect(clean).not.toContain('evil.example'); - expect(clean).not.toContain('evil'); - } - }); - it('preserves local url() paint and filter references', () => { const dirty = ''; @@ -244,89 +231,13 @@ describe('sanitizeSvg', () => { expect(clean).toContain('fill="url(#g2)"'); }); - it('preserves internal stylesheet paint rules', () => { + it('strips stylesheet blocks and inline style attributes', () => { const dirty = - ''; + ''; const clean = sanitizeSvg(dirty); - expect(clean).toContain(' { - const dirty = - ''; - const clean = sanitizeSvg(dirty); - expect(clean).toContain(' close', () => { - const dirty = ''; - const clean = sanitizeSvg(dirty); - expect(clean).not.toContain(' { - // 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( - '', - ); - expect(attrEsc).not.toContain('evil.example'); - const styleEsc = sanitizeSvg( - '', - ); - expect(styleEsc).not.toContain('evil.example'); - expect(styleEsc).toContain('url(#g)'); - }); - - it('strips CSS-escaped @import from internal stylesheets', () => { - const clean = sanitizeSvg( - '', - ); - expect(clean).not.toContain('evil.example'); - }); - - it('keeps co-located local declarations when scrubbing an escaped external ref', () => { - const clean = sanitizeSvg( - '', - ); - expect(clean).not.toContain('evil.example'); - 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'); + expect(clean).not.toContain(' { diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts index fae614ef0d..a79d591607 100644 --- a/client/src/utils/svg.ts +++ b/client/src/utils/svg.ts @@ -113,130 +113,19 @@ export function detectMonochrome(src: string): Promise { }); } -/** 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 `