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 } } })