Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/browser/src/utils/__tests__/generateAvatarDataUri.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('>&lt;&amp;<');
});
});

describe('generateAvatarDataUri — blank variant', () => {
it('renders the gradient background with no text', () => {
const svg = decodeURIComponent(generateAvatarDataUri(params({content: '', variant: 'blank'})));
Expand Down
29 changes: 25 additions & 4 deletions packages/browser/src/utils/generateAvatarDataUri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ const AVATAR_PALETTES: [string, string][] = [
*/
export const AVATAR_GRADIENT_COUNT: number = AVATAR_PALETTES.length;

const XML_ESCAPES: Record<string, string> = {'"': '&quot;', '&': '&amp;', "'": '&apos;', '<': '&lt;', '>': '&gt;'};

/**
* 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,
Expand Down Expand Up @@ -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 = `<text x="50" y="64" font-family="Helvetica,Arial,sans-serif" font-weight="700" font-size="${fontSize}" text-anchor="middle" fill="#fff">${initials}</text>`;
}

const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${defs}${shapeMarkup(shape, background)}${text}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
return toSvgDataUri(svg);
};

/**
Expand All @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapeMarkup(shape, color)}${markup}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
return toSvgDataUri(svg);
};

/**
Expand All @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapeMarkup(shape, color)}${markup}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
return toSvgDataUri(svg);
};

/**
Expand Down
Loading