diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 24f6959b1cf..fbaf1f52742 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -205,6 +205,30 @@ test("test_non_svg_data_avatar_is_rejected", () => { assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null); }); +test("test_legacy_inline_raster_avatar_survives_the_catalog", () => { + for (const mime of ["png", "jpeg", "gif", "webp"]) { + const avatar = `data:image/${mime};base64,iVBORw0KGgo=`; + assert.equal(catalogAvatarUrl(avatar), avatar); + } +}); + +test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => { + const prefix = "data:image/png;base64,"; + const payloadLength = 256 * 1_024 - prefix.length; + const validPayloadLength = payloadLength - (payloadLength % 4); + const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`; + assert.ok(withinCap.length <= 256 * 1_024); + assert.equal(catalogAvatarUrl(withinCap), withinCap); + assert.equal( + catalogAvatarUrl( + `${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`, + ), + null, + ); + assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null); + assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null); +}); + test("test_oversized_inline_svg_avatar_is_rejected", () => { const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; assert.equal(withinCap.length, 8_192); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index c85a976ba69..02c3f8e2023 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -93,6 +93,16 @@ function isSafeHttpUrl(value: unknown): value is string { const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; +/** + * Shared persona heads can carry an uploaded avatar as an inline raster. Keep + * those self-contained images renderable without accepting arbitrary `data:` + * URLs: only the raster MIME types browsers decode in ``, strict base64 + * shape, and a bound no larger than the relay's event-content ceiling. + */ +const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; +const INLINE_RASTER_AVATAR_RE = + /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; + function isInlineSvgAvatar(value: unknown): value is string { return ( typeof value === "string" && @@ -101,6 +111,17 @@ function isInlineSvgAvatar(value: unknown): value is string { ); } +function isInlineRasterAvatar(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > MAX_INLINE_RASTER_AVATAR_LENGTH + ) { + return false; + } + const match = INLINE_RASTER_AVATAR_RE.exec(value); + return match !== null && (match[1]?.length ?? 0) % 4 === 0; +} + function optionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -121,7 +142,9 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { } const avatarUrl = - isSafeHttpUrl(parsed.avatar_url) || isInlineSvgAvatar(parsed.avatar_url) + isSafeHttpUrl(parsed.avatar_url) || + isInlineSvgAvatar(parsed.avatar_url) || + isInlineRasterAvatar(parsed.avatar_url) ? parsed.avatar_url : null; const namePool = Array.isArray(parsed.name_pool)