From 6bb0a832d28a41b38415dd02fb5d3cee9bd5d67d Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 15:51:47 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(gedcom)=20:=20export=20en=205.5.1=20ave?= =?UTF-8?q?c=20SEX=20U=20pour=20un=20sexe=20=C2=AB=20autre=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GEDCOM 5.5.1 n'admet que M, F et U ; SEX X n'existe qu'en 7.0, et l'export écrivait X sous un en-tête 5.5.1. Un sexe « autre » s'écrit maintenant U. L'import lit toujours X comme avant. Conséquence connue : à l'aller-retour, « autre » revient comme « non précisé » (5.5.1 n'a pas de valeur pour « autre »). Décision à trancher, voir la description de la PR. Co-Authored-By: Claude Haiku 5.5 --- backend/src/routes/gedcom.ts | 17 ++++++++++++----- backend/test/api-routes.test.ts | 8 +++++--- backend/test/gedcom-sex.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 backend/test/gedcom-sex.test.ts diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 02c691d..563581e 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -8,7 +8,7 @@ import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } import type { GedcomRecords } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' import { nextSiblingOrder } from '../lib/sibling-order.js' -import { sexOrNull } from '../lib/person-sex.js' +import { sexOrNull, type PersonSex } from '../lib/person-sex.js' const treeIdParamSchema = z.object({ id: z.string().min(1), @@ -285,6 +285,15 @@ function pairKey(a: string, b: string | null): string { return b ? [a, b].sort().join('|') : `${a}|` } +// GEDCOM 5.5.1 n'admet que M, F et U (inconnu, autre). « X » n'existe qu'en 7.0 : un sexe « autre » s'écrit U, +// et l'import relit X comme avant. +export function gedcomSexValue(sex: PersonSex | null): 'M' | 'F' | 'U' | null { + if (sex === 'male') return 'M' + if (sex === 'female') return 'F' + if (sex === 'other') return 'U' + return null +} + function sanitizeXref(id: string): string { return id.replace(/[^A-Za-z0-9_]/g, '_') } @@ -456,10 +465,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`) if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`) - const sex = sexOrNull(person.sex) - if (sex === 'male') lines.push('1 SEX M') - else if (sex === 'female') lines.push('1 SEX F') - else if (sex === 'other') lines.push('1 SEX X') + const sexValue = gedcomSexValue(sexOrNull(person.sex)) + if (sexValue) lines.push(`1 SEX ${sexValue}`) const personEvents = eventsByPerson.get(person.id) ?? [] const birthDetails = personEvents.find((event) => event.type === 'birth') diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 02ea53f..53faa60 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -921,7 +921,7 @@ describe('Sexe dans le GEDCOM (issue #114)', () => { await app.close() }) - it('aller-retour : other sort en SEX X, non précisé sans ligne SEX, et tout se relit à l’identique', async () => { + it('aller-retour : other sort en SEX U (GEDCOM 5.5.1), non précisé sans ligne SEX, le reste se relit à l’identique', async () => { const app = createApp() await app.ready() const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() }) @@ -940,10 +940,12 @@ describe('Sexe dans le GEDCOM (issue #114)', () => { const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } }) expect(response.statusCode).toBe(200) const text = response.body.replace(/\r\n/g, '\n') - expect(text).toContain('1 NAME Camille /Martin/\n1 SEX X\n') + expect(text).toContain('1 NAME Camille /Martin/\n1 SEX U\n') + expect(text).not.toContain('SEX X') expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/) - expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Claude: null }) + // SEX U se relit comme non précisé : 5.5.1 n'a pas de valeur pour « autre ». SEX X reste lu à l'import (ligne 915). + expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: null, Claude: null }) await app.close() }) }) diff --git a/backend/test/gedcom-sex.test.ts b/backend/test/gedcom-sex.test.ts new file mode 100644 index 0000000..3943b2f --- /dev/null +++ b/backend/test/gedcom-sex.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { parseSex } from '../src/lib/person-sex' +import { gedcomSexValue, parseGedcom } from '../src/routes/gedcom' + +// Export en GEDCOM 5.5.1 : SEX ne vaut que M, F ou U (#118). « X » n'existe qu'en GEDCOM 7. +describe('export GEDCOM 5.5.1 : valeur SEX', () => { + it('écrit M, F, et U pour un sexe « autre »', () => { + expect(gedcomSexValue('male')).toBe('M') + expect(gedcomSexValue('female')).toBe('F') + expect(gedcomSexValue('other')).toBe('U') + }) + + it("n'écrit jamais X, et ne déclare rien pour un sexe non précisé", () => { + for (const sex of ['male', 'female', 'other', null] as const) { + expect(gedcomSexValue(sex)).not.toBe('X') + } + expect(gedcomSexValue(null)).toBeNull() + }) + + it('l’import relit toujours SEX X comme « autre », et U comme non précisé', () => { + const sexes = parseGedcom(['0 @I1@ INDI', '1 SEX X', '0 @I2@ INDI', '1 SEX U'].join('\n')) + .filter((node) => node.tag === 'INDI') + .map((indi) => parseSex(indi.children.find((child) => child.tag === 'SEX')?.value ?? null)) + expect(sexes).toEqual(['other', null]) + }) +}) From 25b33ca0560a590481bf0b36a4ca08d83012e36b Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 15:51:47 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(comptes)=20:=20suppression=20sans=20mot?= =?UTF-8?q?=20de=20passe=20si=20PASSWORD=5FLOGIN=3Dfalse,=20identit=C3=A9?= =?UTF-8?q?=20OIDC=20li=C3=A9e=20par=20sub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression de compte : un compte qui avait un mot de passe mais qui est relié à OIDC, sur une instance avec PASSWORD_LOGIN=false, ne pouvait plus se supprimer (le mot de passe ne sert plus). Il se confirme désormais par son email après une connexion récente, comme un compte né du fournisseur. /me et l'échange OIDC renvoient hasPassword=false dans ce cas, pour que l'écran demande l'email : aucun changement d'interface. Identité OIDC : un email vérifié déjà relié à une autre identité (un autre sujet) n'est plus repris par un nouveau sujet (oidc_email_taken). La connexion d'une identité déjà reliée reste faite par émetteur et sujet, jamais par email. Co-Authored-By: Claude Haiku 5.5 --- backend/src/lib/auth.ts | 11 ++++- backend/src/routes/auth.ts | 16 ++++--- backend/src/routes/oidc.ts | 11 ++++- backend/test/data-deletion.test.ts | 71 +++++++++++++++++++++++++++++- backend/test/oidc.test.ts | 23 ++++++++++ 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index edc534b..60b5737 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,5 +1,7 @@ import bcrypt from 'bcryptjs' +import { env } from '../config/env.js' + const HASH_ROUNDS = 12 export async function hashPassword(value: string): Promise { @@ -8,4 +10,11 @@ export async function hashPassword(value: string): Promise { export async function verifyPassword(value: string, hash: string): Promise { return bcrypt.compare(value, hash) -} \ No newline at end of file +} + +// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte. +// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le +// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente. +export function confirmsWithPassword(passwordHash: string | null | undefined): boolean { + return Boolean(passwordHash) && env.PASSWORD_LOGIN +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index e47179d..1499d9c 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { env } from '../config/env.js' -import { hashPassword, verifyPassword } from '../lib/auth.js' +import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js' import { isOidcEnabled } from '../lib/oidc.js' import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js' import { signUserSession } from '../lib/user-session.js' @@ -237,7 +237,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { } // hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe - return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) } }) + return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } }) }) app.get('/me/last-tree', async (request, reply) => { @@ -379,17 +379,19 @@ export const authRoutes: FastifyPluginAsync = async (app) => { const payload = await request.jwtVerify() - if (!user.passwordHash) { - // Compte né d'une connexion OIDC : pas de mot de passe à redonner. Il retape son email, et la session doit - // venir d'une connexion récente chez le fournisseur (une session volée ou oubliée ouverte ne suffit pas). + const withPassword = confirmsWithPassword(user.passwordHash) + if (!withPassword) { + // Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à + // redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur + // (une session volée ou oubliée ouverte ne suffit pas). const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity if (sessionAgeMs > RECENT_SIGN_IN_MS) { return reply.code(403).send({ error: 'recent_sign_in_required' }) } } - const isValid = user.passwordHash - ? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash) + const isValid = withPassword + ? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string) : (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email if (!isValid) { return reply.code(403).send({ error: 'invalid_password' }) diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index be38479..ef12b63 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -13,6 +13,7 @@ import { type OidcIdentity, type OidcPending, } from '../lib/oidc.js' +import { confirmsWithPassword } from '../lib/auth.js' import { createAccountIfAllowed } from '../lib/registration.js' import { signUserSession } from '../lib/user-session.js' @@ -64,7 +65,10 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro if (!identity.email) return { error: 'oidc_email_missing' } if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' } - const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true, passwordHash: true } }) + const existing = await app.prisma.user.findUnique({ + where: { email: identity.email }, + select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } }, + }) if (existing) { // Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false : // sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte @@ -72,6 +76,9 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro // Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce // compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers if (existing.passwordHash) return { error: 'oidc_email_taken' } + // Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui + // présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié. + if (existing.identities?.length) return { error: 'oidc_email_taken' } await app.prisma.userIdentity.create({ data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject }, @@ -210,7 +217,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => { return reply.send({ token: signUserSession(app, user), - user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) }, + user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) }, }) }, ) diff --git a/backend/test/data-deletion.test.ts b/backend/test/data-deletion.test.ts index e6b4e07..0919f42 100644 --- a/backend/test/data-deletion.test.ts +++ b/backend/test/data-deletion.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import type { FastifyInstance } from 'fastify' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { prismaMock } = vi.hoisted(() => ({ prismaMock: { @@ -53,6 +53,7 @@ let storageRoot = '' let passwordHash = '' let createApp: () => FastifyInstance let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null +let env: { PASSWORD_LOGIN: boolean } function applyTestEnv(): void { process.env.NODE_ENV = 'test' @@ -97,6 +98,7 @@ beforeAll(async () => { createApp = (await import('../src/app.js')).createApp resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory + ;({ env } = await import('../src/config/env.js')) }) afterAll(async () => { @@ -220,6 +222,73 @@ describe('DELETE /auth/me', () => { }, 15000) }) + describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => { + const signedAt = async (app: FastifyInstance, minutesAgo: number) => { + await app.ready() + return (app as any).jwt.sign({ + kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com', + iat: Math.floor(Date.now() / 1000) - minutesAgo * 60, + }, { expiresIn: '1h' }) + } + + beforeEach(() => { + env.PASSWORD_LOGIN = false + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash }) + }) + + afterEach(() => { + env.PASSWORD_LOGIN = true + }) + + it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(200) + expect(prismaMock.user.delete).toHaveBeenCalled() + await app.close() + }) + + it('refuse une session de plus de 15 minutes, même avec le bon email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 20)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(response.json()).toEqual({ error: 'recent_sign_in_required' }) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('refuse un autre email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'autre@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => { + const app = createApp() + const response = await app.inject({ + method: 'GET', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + }) + expect(response.statusCode).toBe(200) + expect(response.json().user.hasPassword).toBe(false) + await app.close() + }) + }) + it('rejects a wrong password without deleting anything', async () => { const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg') const app = createApp() diff --git a/backend/test/oidc.test.ts b/backend/test/oidc.test.ts index 154e60a..ca4c795 100644 --- a/backend/test/oidc.test.ts +++ b/backend/test/oidc.test.ts @@ -168,6 +168,29 @@ describe('compte relié, créé ou refusé', () => { expect(prismaMock.user.create).not.toHaveBeenCalled() }) + it('identité déjà reliée : retrouvée par émetteur et sujet, jamais par email (le fournisseur a pu le réattribuer)', async () => { + prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'reattribue@example.com' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(prismaMock.userIdentity.findUnique).toHaveBeenCalledWith({ + where: { issuer_subject: { issuer: ISSUER, subject: 'sub-1' } }, + select: { userId: true }, + }) + expect(prismaMock.user.findUnique).not.toHaveBeenCalled() + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) + + it("nouveau sujet avec un email vérifié déjà relié à un autre sujet : refusé, le compte ne change pas de mains", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null, identities: [{ id: 'identity-1' }] }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, subject: 'sub-2' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken') + expect(prismaMock.userIdentity.create).not.toHaveBeenCalled() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => { prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' }) const { cookie } = await start() From 0fb4aeec0d4ecb2ba9e10fb37a79ec87dec3a691 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 15:51:48 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(m=C3=A9dias)=20:=20HEIC=20et=20AVIF=20r?= =?UTF-8?q?econnus=20dans=20les=20octets,=20plus=20typ=C3=A9s=20vid=C3=A9o?= =?UTF-8?q?/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 } } })