diff --git a/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts b/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts index 5b7f07b5..aaf719bd 100644 --- a/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts +++ b/packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts @@ -47,6 +47,32 @@ describe('generateAvatarDataUri — letter variants', () => { }); }); +describe('generateAvatarDataUri — untrusted content', () => { + it('counts letters by code point so an emoji is never split', () => { + expect(decodeURIComponent(generateAvatarDataUri(params({content: 'B😈'})))).toContain('>B😈<'); + expect(decodeURIComponent(generateAvatarDataUri(params({content: '😈B', variant: 'one_letter'})))).toContain( + '>😈<', + ); + }); + + it('replaces a lone surrogate in content instead of throwing', () => { + expect(decodeURIComponent(generateAvatarDataUri(params({content: '\uD83DB'})))).toContain('>\uFFFDB<'); + expect(decodeURIComponent(generateAvatarDataUri(params({content: 'B\uDE08'})))).toContain('>B\uFFFD<'); + }); + + it('replaces a lone surrogate in bg instead of throwing', () => { + expect(() => generateAvatarDataUri(params({content: 'AC', bg: '\uD83D'}))).not.toThrow(); + expect(() => generateAvatarDataUri(params({content: '', variant: 'blank', bg: '\uD83D'}))).not.toThrow(); + expect(() => generateAvatarDataUri(params({variant: 'anonymous_animal', bg: '\uD83D'}))).not.toThrow(); + expect(() => generateAvatarDataUri(params({variant: 'anonymous_entity', bg: '\uD83D'}))).not.toThrow(); + }); + + it('escapes markup characters in content', () => { + const svg = decodeURIComponent(generateAvatarDataUri(params({content: '<&'}))); + expect(svg).toContain('><&<'); + }); +}); + describe('generateAvatarDataUri — blank variant', () => { it('renders the gradient background with no text', () => { const svg = decodeURIComponent(generateAvatarDataUri(params({content: '', variant: 'blank'}))); diff --git a/packages/browser/src/utils/generateAvatarDataUri.ts b/packages/browser/src/utils/generateAvatarDataUri.ts index af8c9f83..addd9699 100644 --- a/packages/browser/src/utils/generateAvatarDataUri.ts +++ b/packages/browser/src/utils/generateAvatarDataUri.ts @@ -30,6 +30,23 @@ const AVATAR_PALETTES: [string, string][] = [ */ export const AVATAR_GRADIENT_COUNT: number = AVATAR_PALETTES.length; +const XML_ESCAPES: Record = {'"': '"', '&': '&', "'": ''', '<': '<', '>': '>'}; + +/** + * Escapes a caller-supplied value for interpolation into SVG text. + */ +const escapeXml = (value: string): string => value.replace(/["&'<>]/g, (ch: string) => XML_ESCAPES[ch]); + +/** + * Encodes an SVG document as a data URI. Lone UTF-16 surrogates are replaced with U+FFFD + * first, since `encodeURIComponent` throws a `URIError` on them. `Array.from` iterates by + * code point, so a surrogate it yields on its own is unpaired. + */ +const toSvgDataUri = (svg: string): string => + `data:image/svg+xml,${encodeURIComponent( + Array.from(svg, (ch: string) => (ch.length === 1 && ch >= '\uD800' && ch <= '\uDFFF' ? '\uFFFD' : ch)).join(''), + )}`; + /** * Fills in `params.content` when the spec itself didn't carry one, deriving it from a raw * seed name (e.g. an app or user display name) — extracting initials for letter variants, @@ -96,13 +113,17 @@ const generateGradientAvatar = ({shape, variant, content, colors, bg}: AvatarPar let text = ''; if (variant !== 'blank') { const letterCount: 1 | 2 = variant === 'one_letter' ? 1 : 2; - const initials: string = (content || 'A').toUpperCase().slice(0, letterCount); + const initials: string = escapeXml( + Array.from((content || 'A').toUpperCase()) + .slice(0, letterCount) + .join(''), + ); const fontSize: number = letterCount === 1 ? 46 : 34; text = `${initials}`; } const svg = `${defs}${shapeMarkup(shape, background)}${text}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; + return toSvgDataUri(svg); }; /** @@ -119,7 +140,7 @@ const generateAnonymousAnimalAvatar = ({shape, content, bg}: AvatarParams): stri const color: string = bg ?? icon.color; const markup: string = icon.markup.replaceAll('%COLOR%', color); const svg = `${shapeMarkup(shape, color)}${markup}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; + return toSvgDataUri(svg); }; /** @@ -136,7 +157,7 @@ const generateAnonymousEntityAvatar = ({shape, content, bg}: AvatarParams): stri const color: string = bg ?? icon.color; const markup: string = icon.markup.replaceAll('%COLOR%', color); const svg = `${shapeMarkup(shape, color)}${markup}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; + return toSvgDataUri(svg); }; /**