From 9f2519372fe3ef9affe6f08475f00f99709d7b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Tue, 30 Jun 2026 10:52:57 +0200 Subject: [PATCH] fix(avatar): sniff avatar image bytes for the cached MIME type PEP avatar fetches in Profile.ts hardcoded image/png when caching, so animated GIF/WebP/APNG avatars were stored as a Blob typed image/png. Browsers content-sniff so they still rendered, but consumers that trust blob.type (the GIF freeze-frame extractor, notification avatar file extensions) were misled. Add sniffImageMimeType() which reads the magic bytes (PNG, APNG via the acTL chunk, GIF, WebP, JPEG) and use it at the three PEP cacheAvatar sites: fetchAvatarData, fetchOccupantAvatar, and fetchOwnAvatar (where it also overrides a mislabeled ). Unknown formats fall back to image/png. --- .../src/core/modules/Profile.avatar.test.ts | 143 ++++++++++++++++++ .../fluux-sdk/src/core/modules/Profile.ts | 16 +- .../fluux-sdk/src/utils/imageType.test.ts | 94 ++++++++++++ packages/fluux-sdk/src/utils/imageType.ts | 122 +++++++++++++++ 4 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 packages/fluux-sdk/src/utils/imageType.test.ts create mode 100644 packages/fluux-sdk/src/utils/imageType.ts diff --git a/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts b/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts index b3ed21d0..29d0ec09 100644 --- a/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts +++ b/packages/fluux-sdk/src/core/modules/Profile.avatar.test.ts @@ -16,6 +16,15 @@ import { type MockStoreBindings, } from '../test-utils' +/** Base64-encode a small byte array, for crafting avatar payloads with real magic bytes. */ +const toBase64 = (bytes: number[]) => btoa(String.fromCharCode(...bytes)) + +// Minimal payloads whose leading bytes identify a non-png image format. +const GIF_BASE64 = toBase64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00]) // "GIF89a" +const WEBP_BASE64 = toBase64([ + 0x52, 0x49, 0x46, 0x46, 0x1a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, +]) // "RIFF....WEBP" + let mockXmppClientInstance: MockXmppClient // Use vi.hoisted to create the mock factory at hoist time @@ -295,6 +304,73 @@ describe('XMPPClient Own Avatar', () => { expect(mockXmppClientInstance.iqCaller.request).toHaveBeenCalledTimes(1) expect(emitSDKSpy).toHaveBeenCalledWith('connection:own-avatar', { avatar: 'blob:cached-existing', hash: 'abc123hash' }) }) + + it('caches the own avatar with its sniffed type, overriding a mislabeled ', async () => { + mockXmppClientInstance.iqCaller.request.mockClear() + + const { getCachedAvatar, cacheAvatar } = await import('../../utils/avatarCache') + vi.mocked(getCachedAvatar).mockResolvedValue(null) + vi.mocked(cacheAvatar).mockResolvedValue('blob:own-gif') + + // Metadata advertises image/png... + const metadataResponse = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:avatar:metadata' }, + children: [ + { + name: 'item', + attrs: { id: 'own-hash' }, + children: [ + { + name: 'metadata', + attrs: { xmlns: 'urn:xmpp:avatar:metadata' }, + children: [ + { name: 'info', attrs: { id: 'own-hash', type: 'image/png', bytes: '128' } }, + ], + }, + ], + }, + ], + }, + ], + }, + ]) + // ...but the actual bytes are a GIF. + const dataResponse = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:avatar:data' }, + children: [ + { + name: 'item', + attrs: { id: 'own-hash' }, + children: [ + { name: 'data', attrs: { xmlns: 'urn:xmpp:avatar:data' }, text: GIF_BASE64 }, + ], + }, + ], + }, + ], + }, + ]) + mockXmppClientInstance.iqCaller.request + .mockResolvedValueOnce(metadataResponse) + .mockResolvedValueOnce(dataResponse) + + await xmppClient.profile.fetchOwnAvatar() + + // The advertised image/png is only a fallback; the GIF bytes win. + expect(cacheAvatar).toHaveBeenCalledWith('own-hash', GIF_BASE64, 'image/gif') + }) }) describe('fetchContactAvatarMetadata', () => { @@ -1455,6 +1531,35 @@ describe('XMPPClient Own Avatar', () => { avatarHash: 'avatar-hash', }) }) + + it('caches a non-png occupant PEP avatar with the MIME type sniffed from its bytes', async () => { + mockXmppClientInstance.iqCaller.request.mockClear() + + const { getCachedAvatar, cacheAvatar } = await import('../../utils/avatarCache') + vi.mocked(getCachedAvatar).mockResolvedValueOnce(null) + vi.mocked(cacheAvatar).mockResolvedValue('blob:webp-occupant') + + // Occupant PEP data node carries WebP bytes (no advertised type on the wire). + const pepResponse = createMockElement('iq', { type: 'result' }, [ + { name: 'pubsub', attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, children: [ + { name: 'items', children: [ + { name: 'item', attrs: { id: 'webp-hash' }, children: [ + { name: 'data', attrs: { xmlns: 'urn:xmpp:avatar:data' }, text: WEBP_BASE64 }, + ] }, + ] }, + ] }, + ]) + mockXmppClientInstance.iqCaller.request.mockResolvedValueOnce(pepResponse) + + await xmppClient.profile.fetchOccupantAvatar( + 'room@conference.example.com', + 'WebpUser', + 'webp-hash', + 'webpuser@example.com' + ) + + expect(cacheAvatar).toHaveBeenCalledWith('webp-hash', WEBP_BASE64, 'image/webp') + }) }) describe('fetchOccupantAvatar saves JID→hash mapping', () => { @@ -1680,6 +1785,44 @@ describe('XMPPClient Own Avatar', () => { expect(cacheAvatar).toHaveBeenCalledWith('new-hash', 'iVBORw0KGgo=', 'image/png') expect(saveAvatarHash).toHaveBeenCalledWith('contact@example.com', 'new-hash', 'contact') }) + + it('caches a non-png PEP avatar with the MIME type sniffed from its bytes', async () => { + mockXmppClientInstance.iqCaller.request.mockClear() + emitSDKSpy.mockClear() + + const { getCachedAvatar, cacheAvatar } = await import('../../utils/avatarCache') + vi.mocked(getCachedAvatar).mockResolvedValueOnce(null) + vi.mocked(cacheAvatar).mockResolvedValueOnce('blob:gif-avatar') + + // PEP data node carries GIF bytes (XEP-0084 data responses have no type). + const dataResponse = createMockElement('iq', { type: 'result' }, [ + { + name: 'pubsub', + attrs: { xmlns: 'http://jabber.org/protocol/pubsub' }, + children: [ + { + name: 'items', + attrs: { node: 'urn:xmpp:avatar:data' }, + children: [ + { + name: 'item', + attrs: { id: 'gif-hash' }, + children: [ + { name: 'data', attrs: { xmlns: 'urn:xmpp:avatar:data' }, text: GIF_BASE64 }, + ], + }, + ], + }, + ], + }, + ]) + mockXmppClientInstance.iqCaller.request.mockResolvedValueOnce(dataResponse) + + await xmppClient.profile.fetchAvatarData('contact@example.com', 'gif-hash') + + // Cached as image/gif, not the old hardcoded image/png. + expect(cacheAvatar).toHaveBeenCalledWith('gif-hash', GIF_BASE64, 'image/gif') + }) }) describe('avatarMetadataUpdate event dedup', () => { diff --git a/packages/fluux-sdk/src/core/modules/Profile.ts b/packages/fluux-sdk/src/core/modules/Profile.ts index ac91a47c..2348f684 100644 --- a/packages/fluux-sdk/src/core/modules/Profile.ts +++ b/packages/fluux-sdk/src/core/modules/Profile.ts @@ -5,6 +5,7 @@ import { getBareJid, getLocalPart, getDomain } from '../jid' import type { VCardInfo } from '../types/roster' import { generateUUID } from '../../utils/uuid' import { getCachedAvatar, getAvatarHash, cacheAvatar, saveAvatarHash, getAllAvatarHashes, hasNoAvatar, markNoAvatar, clearNoAvatar, refreshAllBlobUrls, isPepForbiddenDomain, markPepForbiddenDomain, loadPepForbiddenDomains } from '../../utils/avatarCache' +import { sniffImageMimeType } from '../../utils/imageType' import { NS_PUBSUB, NS_NICK, @@ -86,8 +87,12 @@ export class Profile extends BaseModule { const data = result.getChild('pubsub', NS_PUBSUB)?.getChild('items')?.getChild('item')?.getChild('data', 'urn:xmpp:avatar:data')?.text() if (data) { + // XEP-0084 data responses carry no MIME type, so sniff the bytes rather + // than assume PNG — otherwise animated GIF/WebP/APNG avatars get a Blob + // typed image/png and any consumer trusting blob.type is misled. + const mimeType = sniffImageMimeType(data) ?? 'image/png' // Cache to IndexedDB and get a blob URL - const blobUrl = await cacheAvatar(hash, data, 'image/png') + const blobUrl = await cacheAvatar(hash, data, mimeType) await saveAvatarHash(bareJid, hash, 'contact') this.updateAvatar(bareJid, blobUrl, hash) // Clear negative cache since we found an avatar @@ -317,7 +322,9 @@ export class Profile extends BaseModule { const data = result.getChild('pubsub', NS_PUBSUB)?.getChild('items')?.getChild('item')?.getChild('data', NS_AVATAR_DATA)?.text() if (data) { - const mimeType = 'image/png' + // The data node has no MIME type; sniff the bytes so animated + // avatars aren't cached as image/png (see fetchAvatarData). + const mimeType = sniffImageMimeType(data) ?? 'image/png' const blobUrl = await cacheAvatar(avatarHash, data, mimeType) await clearNoAvatar(bareJid) // Persist JID→hash mapping so we can restore from cache on next session @@ -802,7 +809,10 @@ export class Profile extends BaseModule { if (data) { const base64 = data.text() if (base64) { - const blobUrl = await cacheAvatar(hash, base64, mimeType) + // Prefer the sniffed type over the advertised , which the + // publishing client may have mislabeled; fall back to it when unknown. + const sniffedType = sniffImageMimeType(base64) ?? mimeType + const blobUrl = await cacheAvatar(hash, base64, sniffedType) await saveAvatarHash(bareJid, hash, 'contact') this.deps.emitSDK('connection:own-avatar', { avatar: blobUrl, hash }) } diff --git a/packages/fluux-sdk/src/utils/imageType.test.ts b/packages/fluux-sdk/src/utils/imageType.test.ts new file mode 100644 index 00000000..7644ec28 --- /dev/null +++ b/packages/fluux-sdk/src/utils/imageType.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { sniffImageMimeType } from './imageType' + +/** Base64-encode a byte array (small fixtures only). */ +const b64 = (bytes: number[]) => btoa(String.fromCharCode(...bytes)) + +/** Build a PNG chunk: 4-byte big-endian length, 4-byte ASCII type, data, 4-byte CRC. */ +function pngChunk(type: string, dataLen = 0): number[] { + const typeBytes = [...type].map((c) => c.charCodeAt(0)) + const len = [ + (dataLen >>> 24) & 0xff, + (dataLen >>> 16) & 0xff, + (dataLen >>> 8) & 0xff, + dataLen & 0xff, + ] + const data = new Array(dataLen).fill(0) + const crc = [0, 0, 0, 0] + return [...len, ...typeBytes, ...data, ...crc] +} + +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +describe('sniffImageMimeType', () => { + it('detects a still PNG from its 8-byte signature', () => { + const png = b64([...PNG_SIG, ...pngChunk('IHDR', 13), ...pngChunk('IDAT', 0)]) + expect(sniffImageMimeType(png)).toBe('image/png') + }) + + it('detects an animated PNG (acTL before IDAT) as image/apng', () => { + const apng = b64([ + ...PNG_SIG, + ...pngChunk('IHDR', 13), + ...pngChunk('acTL', 8), + ...pngChunk('IDAT', 0), + ]) + expect(sniffImageMimeType(apng)).toBe('image/apng') + }) + + it('treats an acTL chunk after IDAT as a still PNG (not APNG)', () => { + // Per the APNG spec the animation-control chunk must precede image data. + const png = b64([ + ...PNG_SIG, + ...pngChunk('IHDR', 13), + ...pngChunk('IDAT', 0), + ...pngChunk('acTL', 8), + ]) + expect(sniffImageMimeType(png)).toBe('image/png') + }) + + it('detects a GIF87a image', () => { + expect(sniffImageMimeType(b64([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]))).toBe('image/gif') + }) + + it('detects a GIF89a image', () => { + expect(sniffImageMimeType(b64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]))).toBe('image/gif') + }) + + it('detects a WebP image (RIFF....WEBP)', () => { + const webp = b64([ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x1a, 0x00, 0x00, 0x00, // file size + 0x57, 0x45, 0x42, 0x50, // "WEBP" + 0x56, 0x50, 0x38, 0x20, // "VP8 " + ]) + expect(sniffImageMimeType(webp)).toBe('image/webp') + }) + + it('detects a JPEG image', () => { + const jpeg = b64([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]) + expect(sniffImageMimeType(jpeg)).toBe('image/jpeg') + }) + + it('tolerates whitespace/newlines in the base64 (XML-wrapped payloads)', () => { + const gif = b64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) + const wrapped = gif.slice(0, 4) + '\n ' + gif.slice(4) + expect(sniffImageMimeType(wrapped)).toBe('image/gif') + }) + + it('returns null for an unrecognized format', () => { + expect(sniffImageMimeType(b64([0x00, 0x01, 0x02, 0x03, 0x04]))).toBeNull() + }) + + it('returns null for an empty string', () => { + expect(sniffImageMimeType('')).toBeNull() + }) + + it('returns null for invalid base64', () => { + expect(sniffImageMimeType('!!!!not-base64')).toBeNull() + }) + + it('returns null for data too short to identify', () => { + expect(sniffImageMimeType(b64([0x89, 0x50]))).toBeNull() + }) +}) diff --git a/packages/fluux-sdk/src/utils/imageType.ts b/packages/fluux-sdk/src/utils/imageType.ts new file mode 100644 index 00000000..7501d2c4 --- /dev/null +++ b/packages/fluux-sdk/src/utils/imageType.ts @@ -0,0 +1,122 @@ +/** + * Sniff an image's MIME type from its magic bytes. + * + * Avatars arrive with a self-reported type: XEP-0084 metadata advertises a + * `` and vCard-temp a ``, both set by the *publishing* + * client. The SDK historically ignored those and hardcoded `image/png` when + * caching PEP avatars, so animated GIF/WebP/APNG avatars were stored as a Blob + * typed `image/png`. Browsers content-sniff `` so they still render, but + * any consumer that trusts `blob.type` (notification file extensions, the GIF + * freeze-frame extractor) is misled. + * + * Rather than trust the advertised type, we sniff the actual bytes — the only + * authoritative source, and robust against clients that mislabel their avatars. + * + * @param base64 - Base64-encoded image data (whitespace tolerated). + * @returns The detected MIME type, or null when the format isn't recognized so + * callers can fall back to a sensible default. + */ +export function sniffImageMimeType(base64: string): string | null { + if (!base64) return null + + const bytes = decodeBase64(base64) + if (!bytes || bytes.length < 3) return null + + // JPEG: FF D8 FF + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + + // GIF: "GIF8" (covers both GIF87a and GIF89a) + if ( + bytes.length >= 4 && + bytes[0] === 0x47 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x38 + ) { + return 'image/gif' + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A. An animated PNG carries an 'acTL' chunk + // before the first 'IDAT'; report those as image/apng. + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return hasApngAnimationChunk(bytes) ? 'image/apng' : 'image/png' + } + + // WebP: "RIFF" <4-byte size> "WEBP" + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'image/webp' + } + + return null +} + +/** + * Decode base64 to bytes, returning null on malformed input. Avatars are small, + * so the whole payload is decoded; whitespace (XML-wrapped base64) is stripped. + */ +function decodeBase64(base64: string): Uint8Array | null { + try { + const clean = base64.replace(/\s/g, '') + if (!clean) return null + const binary = atob(clean) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i) + } + return out + } catch { + return null + } +} + +/** + * Walk PNG chunks looking for an 'acTL' animation-control chunk positioned + * before the first 'IDAT'. Caller guarantees `bytes` starts with the PNG + * signature. + */ +function hasApngAnimationChunk(bytes: Uint8Array): boolean { + let offset = 8 // skip the 8-byte PNG signature + while (offset + 8 <= bytes.length) { + // Big-endian 4-byte chunk length (avoid << to stay unsigned). + const length = + bytes[offset] * 0x1000000 + + (bytes[offset + 1] << 16) + + (bytes[offset + 2] << 8) + + bytes[offset + 3] + const t0 = bytes[offset + 4] + const t1 = bytes[offset + 5] + const t2 = bytes[offset + 6] + const t3 = bytes[offset + 7] + + // 'acTL' + if (t0 === 0x61 && t1 === 0x63 && t2 === 0x54 && t3 === 0x4c) return true + // 'IDAT' — animation control must precede image data, so stop here. + if (t0 === 0x49 && t1 === 0x44 && t2 === 0x41 && t3 === 0x54) return false + + // Advance past length(4) + type(4) + data(length) + CRC(4). + offset += 12 + length + } + return false +}