From 91c93ddfa18c32d3358d30436eb07bca390725d8 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 30 Sep 2026 17:57:27 +0200 Subject: [PATCH] Tests de l'audit 1.0 : fichiers, annotations, jetons, mot de passe de partage Co-Authored-By: Claude Opus 5.5 --- backend/test/api-routes.test.ts | 9 +- backend/test/api-security.integration.test.ts | 81 ++++++++++-- backend/test/e2e-journeys.test.ts | 9 +- backend/test/security-hardening.test.ts | 124 ++++++++++++++++++ 4 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 backend/test/security-hardening.test.ts diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index fdec471..8a97597 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -35,7 +35,7 @@ const { prismaMock } = vi.hoisted(() => ({ findMany: vi.fn(), findUnique: vi.fn(), upsert: vi.fn(), - updateMany: vi.fn(), + updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), }, person: { findMany: vi.fn(), @@ -59,7 +59,7 @@ const { prismaMock } = vi.hoisted(() => ({ updateMany: vi.fn(), }, mediaItem: { - count: vi.fn(), + count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }), create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), @@ -73,7 +73,7 @@ const { prismaMock } = vi.hoisted(() => ({ create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), - update: vi.fn(), + update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }), count: vi.fn(), }, contributionChange: { @@ -128,6 +128,9 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.userTreeAccess.deleteMany?.mockResolvedValue({ count: 0 }) + prismaMock.contributionSession.updateMany?.mockResolvedValue({ count: 1 }) + prismaMock.mediaItem.aggregate?.mockResolvedValue({ _sum: { sizeBytes: 0 } }) prismaMock.user.findUnique.mockResolvedValue(null) prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }) diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index ebd5f0b..9538097 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -40,7 +40,7 @@ const { prismaMock } = vi.hoisted(() => ({ findMany: vi.fn(), findUnique: vi.fn(), upsert: vi.fn(), - updateMany: vi.fn(), + updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), }, person: { findMany: vi.fn(), @@ -64,7 +64,7 @@ const { prismaMock } = vi.hoisted(() => ({ updateMany: vi.fn(), }, mediaItem: { - count: vi.fn(), + count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }), create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), @@ -78,7 +78,7 @@ const { prismaMock } = vi.hoisted(() => ({ create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), - update: vi.fn(), + update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }), count: vi.fn(), }, contributionChange: { @@ -100,6 +100,9 @@ vi.mock('../src/lib/prisma.js', () => ({ let createApp: () => FastifyInstance +// Vraie image d'un pixel : le serveur reconnaît les fichiers à leurs octets, plus au type déclaré +const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64') + function applyTestEnv(): void { process.env.NODE_ENV = 'test' process.env.PORT = '4000' @@ -134,6 +137,9 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.userTreeAccess.deleteMany?.mockResolvedValue({ count: 0 }) + prismaMock.contributionSession.updateMany?.mockResolvedValue({ count: 1 }) + prismaMock.mediaItem.aggregate?.mockResolvedValue({ _sum: { sizeBytes: 0 } }) prismaMock.user.findUnique.mockResolvedValue(null) prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }) @@ -1021,8 +1027,8 @@ describe('API security and authz', () => { type: 'photo', fileName: 'portrait.jpg', mimeType: 'image/jpeg', - sizeBytes: 5, - dataBase64: Buffer.from('hello').toString('base64'), + sizeBytes: PNG_1PX.length, + dataBase64: PNG_1PX.toString('base64'), caption: 'Portrait famille', }, }) @@ -1059,8 +1065,8 @@ describe('API security and authz', () => { type: 'photo', fileName: 'portrait.jpg', mimeType: 'image/jpeg', - sizeBytes: 5, - dataBase64: Buffer.from('hello').toString('base64'), + sizeBytes: PNG_1PX.length, + dataBase64: PNG_1PX.toString('base64'), } it('turns a member media upload into a pending contribution', async () => { @@ -1346,10 +1352,9 @@ describe('API security and authz', () => { await app.close() }) - it('rejects mismatched mime type for gpx media', async () => { + it('rejects a gpx upload whose content is not a gpx track', async () => { const app = createApp() const token = await createUserToken(app) - const fakePayload = '' const response = await app.inject({ method: 'POST', @@ -1358,9 +1363,9 @@ describe('API security and authz', () => { payload: { type: 'gpx', fileName: 'trace.gpx', - mimeType: 'image/png', - sizeBytes: Buffer.byteLength(fakePayload), - dataBase64: Buffer.from(fakePayload).toString('base64'), + mimeType: 'application/gpx+xml', + sizeBytes: PNG_1PX.length, + dataBase64: PNG_1PX.toString('base64'), }, }) @@ -1486,4 +1491,56 @@ describe('API security and authz', () => { }) await app.close() }) + + // Audit 1.0 (30/09/2026) + it('refuses the annotations of a tree the account has no access to', async () => { + const app = createApp() + const token = await createUserToken(app, 'stranger') + prismaMock.treeMembership.findUnique.mockResolvedValue(null) + prismaMock.userTreeAccess.findUnique.mockResolvedValue(null) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/annotations', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(403) + expect(prismaMock.annotation.findMany).not.toHaveBeenCalled() + await app.close() + }) + + it('no longer accepts an account session in a media address', async () => { + const app = createApp() + const token = await createUserToken(app) + + const response = await app.inject({ + method: 'GET', + url: `/trees/tree-1/media/media-1?token=${encodeURIComponent(token)}`, + }) + + expect(response.statusCode).toBe(401) + await app.close() + }) + + it('cuts accounts linked by the share password when the owner changes it', async () => { + const app = createApp() + const token = await createUserToken(app) + + const response = await app.inject({ + method: 'PATCH', + url: '/trees/tree-1/access/passwords', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'nouveau-mot-de-passe' }, + }) + + expect(response.statusCode).toBe(200) + expect(prismaMock.userTreeAccess.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1' } }) + await app.close() + }) + + it('masks tokens in logged addresses', async () => { + const { redactUrl } = await import('../src/app.js') + expect(redactUrl('/trees/t/media/m?token=abc.def&v=2')).toBe('/trees/t/media/m?token=[masqué]&v=2') + }) }) diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index 3d35d77..dea0160 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -45,7 +45,7 @@ const { prismaMock } = vi.hoisted(() => ({ findMany: vi.fn(), findUnique: vi.fn(), upsert: vi.fn(), - updateMany: vi.fn(), + updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), }, person: { findMany: vi.fn(), @@ -69,7 +69,7 @@ const { prismaMock } = vi.hoisted(() => ({ updateMany: vi.fn(), }, mediaItem: { - count: vi.fn(), + count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }), create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), @@ -83,7 +83,7 @@ const { prismaMock } = vi.hoisted(() => ({ create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn(), - update: vi.fn(), + update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }), count: vi.fn(), }, contributionChange: { @@ -123,6 +123,9 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.userTreeAccess.deleteMany?.mockResolvedValue({ count: 0 }) + prismaMock.contributionSession.updateMany?.mockResolvedValue({ count: 1 }) + prismaMock.mediaItem.aggregate?.mockResolvedValue({ _sum: { sizeBytes: 0 } }) prismaMock.user.findUnique.mockResolvedValue(null) prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'owner@example.com' }) diff --git a/backend/test/security-hardening.test.ts b/backend/test/security-hardening.test.ts new file mode 100644 index 0000000..09a1d7d --- /dev/null +++ b/backend/test/security-hardening.test.ts @@ -0,0 +1,124 @@ +import sharp from 'sharp' +import { describe, expect, it, vi } from 'vitest' + +import { sanitizeAnnotationContentForRead, sanitizeAnnotationContentForWrite } from '../src/lib/annotation-content.js' +import { isMimeAllowedForType, sanitizeImage, scrubMp4Location, sniffMimeType } from '../src/lib/media-files.js' +import { matchSharePassword } from '../src/lib/password-variants.js' +import { isTreeUnlockLocked, recordTreeUnlockFailure, resetTreeUnlockGuard } from '../src/lib/unlock-guard.js' + +const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64') + +function accepted(buf: Buffer, type: Parameters[1], fileName = ''): string | null { + const mime = sniffMimeType(buf, type, fileName) + return mime && isMimeAllowedForType(type, mime) ? mime : null +} + +describe('fichiers envoyés : type lu dans les octets', () => { + it('refuse un SVG envoyé comme photo', () => { + const svg = Buffer.from('') + expect(accepted(svg, 'photo', 'chat.svg')).toBeNull() + }) + + it('refuse du HTML déguisé en PNG', () => { + expect(accepted(Buffer.from(''), 'photo', 'photo.png')).toBeNull() + }) + + it('reconnaît une vraie photo PNG quel que soit le nom', () => { + expect(accepted(PNG_1PX, 'photo', 'photo.jpg')).toBe('image/png') + }) + + it('accepte un tracé GPX et refuse un document XHTML envoyé comme GPX', () => { + expect(accepted(Buffer.from('\n'), 'gpx')).toBe('application/gpx+xml') + expect(accepted(Buffer.from(''), 'gpx')).toBeNull() + }) + + it('refuse un document texte qui commence par une balise', () => { + expect(accepted(Buffer.from(''), 'document', 'note.txt')).toBeNull() + expect(accepted(Buffer.from('Souvenir de la ferme'), 'document', 'note.txt')).toBe('text/plain') + }) + + it('refuse une image envoyée comme GeoJSON', () => { + expect(accepted(PNG_1PX, 'geojson')).toBeNull() + expect(accepted(Buffer.from('{"type":"FeatureCollection","features":[]}'), 'geojson')).toBe('application/geo+json') + }) +}) + +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 } } }) + .jpeg() + .withMetadata({ orientation: 6 }) + .withExifMerge({ IFD3: { GPSLatitudeRef: 'N', GPSLatitude: '50/1 37/1 5/1' } }) + .toBuffer() + expect((await sharp(withGps).metadata()).exif).toBeDefined() + + const cleaned = await sanitizeImage(withGps, 'image/jpeg') + const meta = await sharp(cleaned).metadata() + expect(meta.exif).toBeUndefined() + expect(meta.width).toBe(48) + expect(meta.height).toBe(64) + }) + + it('refuse une image illisible', async () => { + await expect(sanitizeImage(Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01]), 'image/jpeg')).rejects.toThrow('invalid_image') + }) +}) + +describe('vidéos : position ISO 6709 remise à zéro dans moov seulement', () => { + function box(type: string, payload: Buffer): Buffer { + const header = Buffer.alloc(8) + header.writeUInt32BE(8 + payload.length, 0) + header.write(type, 4, 'latin1') + return Buffer.concat([header, payload]) + } + + it('efface les coordonnées de moov sans toucher à mdat', () => { + const ftyp = box('ftyp', Buffer.from('isom0000')) + const mdat = box('mdat', Buffer.from('+50.6300+003.0600/ donnees')) + const moov = box('moov', Buffer.from('udta xyz +50.6300+003.0600+025.000/ fin')) + const scrubbed = scrubMp4Location(Buffer.concat([ftyp, mdat, moov])).toString('latin1') + expect(scrubbed).toContain('+00.0000+000.0000+000.000/ fin') + expect(scrubbed).toContain('+50.6300+003.0600/ donnees') + }) +}) + +describe('annotations photo : jamais d adresse enregistrée', () => { + const treeId = 'tree-1' + + it('retire l adresse (et la session qu elle portait) à l écriture', () => { + const content = JSON.stringify({ photoPath: `/trees/${treeId}/annotation-photos/abc-123`, url: '/api/x?token=SESSION', w: 1, h: 1 }) + const stored = sanitizeAnnotationContentForWrite(content, treeId) + expect(stored).not.toContain('token') + expect(JSON.parse(stored)).toEqual({ photoPath: `/trees/${treeId}/annotation-photos/abc-123`, w: 1, h: 1 }) + }) + + it('refuse un chemin de photo d un autre arbre ou une adresse externe', () => { + expect(() => sanitizeAnnotationContentForWrite(JSON.stringify({ photoPath: '/trees/autre/annotation-photos/abc' }), treeId)).toThrow() + expect(() => sanitizeAnnotationContentForWrite(JSON.stringify({ url: 'https://pisteur.example/pixel.gif' }), treeId)).toThrow() + }) + + it('nettoie les anciennes valeurs à la lecture et laisse le reste intact', () => { + expect(sanitizeAnnotationContentForRead(JSON.stringify({ photoPath: '/p', url: '/x?token=S' }))).toBe(JSON.stringify({ photoPath: '/p' })) + expect(sanitizeAnnotationContentForRead('Mamie à 20 ans')).toBe('Mamie à 20 ans') + expect(sanitizeAnnotationContentForRead(JSON.stringify({ paths: [] }))).toBe(JSON.stringify({ paths: [] })) + }) +}) + +describe('mot de passe de partage', () => { + it('ne compare qu une fois quand les deux colonnes portent le même hash', async () => { + const verify = vi.fn(async () => false) + await matchSharePassword('abcdefgh', ['h1', 'h1'], verify) + expect(verify).toHaveBeenCalledTimes(1) + }) + + it('verrouille un arbre après 30 échecs dans la fenêtre, puis le libère', () => { + resetTreeUnlockGuard() + const now = Date.now() + for (let i = 0; i < 29; i += 1) recordTreeUnlockFailure('tree-x', now) + expect(isTreeUnlockLocked('tree-x', now)).toBe(false) + recordTreeUnlockFailure('tree-x', now) + expect(isTreeUnlockLocked('tree-x', now)).toBe(true) + expect(isTreeUnlockLocked('tree-autre', now)).toBe(false) + expect(isTreeUnlockLocked('tree-x', now + 16 * 60 * 1000)).toBe(false) + }) +})