From 0fb4aeec0d4ecb2ba9e10fb37a79ec87dec3a691 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 15:51:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(m=C3=A9dias)=20:=20HEIC=20et=20AVIF=20recon?= =?UTF-8?q?nus=20dans=20les=20octets,=20plus=20typ=C3=A9s=20vid=C3=A9o/mp4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une photo HEIC (marques heic, heix, mif1, msf1, heim, heis) était typée video/mp4. Elle est maintenant image/heic, et AVIF image/avif. Elles restent refusées à l'envoi (invalid_media_mime_type), comme avant : sharp ne décode que l'AVIF en entrée HEIF, pas le HEVC des HEIC. La marque hevc n'est une photo que si l'envoi se déclare photo, pour qu'une vidéo HEVC en MP4 reste acceptée. Les vignettes Immich ne sont servies que pour les types image autorisés. Co-Authored-By: Claude Haiku 5.5 --- backend/src/lib/media-files.ts | 7 +++++ backend/src/routes/immich.ts | 4 +-- backend/test/security-hardening.test.ts | 39 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/backend/src/lib/media-files.ts b/backend/src/lib/media-files.ts index 70ce049..c0900b4 100644 --- a/backend/src/lib/media-files.ts +++ b/backend/src/lib/media-files.ts @@ -59,6 +59,9 @@ const EXTENSION_BY_MIME: Record = { 'application/gpx+xml': '.gpx', } +// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12) +const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis']) + function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean { if (buf.length < offset + bytes.length) return false return bytes.every((byte, index) => buf[offset + index] === byte) @@ -89,6 +92,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa if (ascii(buf, 4, 8) === 'ftyp') { const brand = ascii(buf, 8, 12) if (brand === 'qt ') return 'video/quicktime' + // HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode + // pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo. + if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic' + if (brand === 'avif' || brand === 'avis') return 'image/avif' if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4' return 'video/mp4' } diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 116fd51..69c053c 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -17,7 +17,7 @@ import { type ImmichPersonSummary, } from '../lib/immich.js' import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js' -import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' +import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js' // Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la @@ -315,7 +315,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { // Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une // balise . Vide, trop grande ou pas une image : rien à montrer. const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null - if (!thumbnail || !mimeType?.startsWith('image/')) { + if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) { request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image') return reply.code(404).send({ error: 'thumbnail_not_found' }) } diff --git a/backend/test/security-hardening.test.ts b/backend/test/security-hardening.test.ts index f3bd286..f5080ad 100644 --- a/backend/test/security-hardening.test.ts +++ b/backend/test/security-hardening.test.ts @@ -49,6 +49,45 @@ describe('fichiers envoyés : type lu dans les octets', () => { }) }) +// Boîte ftyp minimale : taille, « ftyp », marque principale +function ftypBox(brand: string): Buffer { + const box = Buffer.alloc(24) + box.writeUInt32BE(24, 0) + box.write('ftyp', 4, 'latin1') + box.write(brand, 8, 'latin1') + return box +} + +describe('HEIC et AVIF : reconnus, jamais pris pour une vidéo', () => { + it.each(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])('marque %s : image/heic, refusée à l’envoi', (brand) => { + expect(sniffMimeType(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBe('image/heic') + expect(accepted(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBeNull() + expect(accepted(ftypBox(brand), 'video', 'IMG_0001.MOV')).toBeNull() + }) + + it('marque avif : image/avif, refusée à l’envoi (pas de conversion)', () => { + expect(sniffMimeType(ftypBox('avif'), 'photo')).toBe('image/avif') + expect(accepted(ftypBox('avif'), 'photo')).toBeNull() + }) + + it('marque hevc : image/heic quand l’envoi se déclare photo, vidéo MP4 sinon (une vidéo HEVC reste acceptée)', () => { + expect(sniffMimeType(ftypBox('hevc'), 'photo')).toBe('image/heic') + expect(accepted(ftypBox('hevc'), 'video')).toBe('video/mp4') + }) + + it('les vidéos MP4 et QuickTime ne changent pas', () => { + expect(accepted(ftypBox('isom'), 'video')).toBe('video/mp4') + expect(accepted(ftypBox('mp42'), 'video')).toBe('video/mp4') + expect(accepted(ftypBox('qt '), 'video')).toBe('video/quicktime') + expect(accepted(ftypBox('M4A '), 'audio')).toBe('audio/mp4') + }) + + it('une image HEIC n’entre dans aucun type de média autorisé', () => { + expect(isMimeAllowedForType('photo', 'image/heic')).toBe(false) + expect(isMimeAllowedForType('photo', 'image/avif')).toBe(false) + }) +}) + describe('photos : métadonnées retirées, rotation appliquée', () => { it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => { const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } })