Files
parenthese/backend/test/security-hardening.test.ts
T
IliesAllaliandClaude Opus 5.5 75922f3ed3 Sécurité 1.0.1 : audit indépendant de la 1.0
- Vidéo piégée : l'expression régulière de position était quadratique, un faux MP4 de 2 Mo figeait l'API (bornée, linéaire)
- Sessions émises avant le 01/10/2026 refusées (SESSIONS_NOT_BEFORE) : des sessions ont fuité dans les annotations et les journaux jusqu'à la 1.0
- Contributions : l'« avant » montré au propriétaire vient de la base ; une suppression approuvée part en cascade (unions, liens, souvenirs, portrait, fichiers)
- Mot de passe de partage : essai compté avant bcrypt, comparaisons une par une, changement et rattachement dans une transaction
- Quota mesuré sur le disque, 10 arbres par compte, corps à 1 Mo hors envois, bornes sur annotations, contributions et GEDCOM
- GIF réencodés ; anciennes propositions d'annotation sans adresse stockée ; clé d'exemple refusée sans NODE_ENV
- Déploiement du commit validé par la CI, clé d'hôte obligatoire ; nginx : X-Forwarded-For écrasé, version masquée, journal sans query string
- PostHog verrouillé dans le code, sans script externe ; CSP sans *.posthog.com ; sauvegardes hors serveur chiffrées par défaut

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:19:41 +02:00

191 lines
8.9 KiB
TypeScript

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,
reserveTreeUnlockAttempt,
resetTreeUnlockGuard,
serializePasswordCheck,
} from '../src/lib/unlock-guard.js'
const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
function accepted(buf: Buffer, type: Parameters<typeof sniffMimeType>[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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')
expect(accepted(svg, 'photo', 'chat.svg')).toBeNull()
})
it('refuse du HTML déguisé en PNG', () => {
expect(accepted(Buffer.from('<html><script>alert(1)</script></html>'), '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('<?xml version="1.0"?>\n<gpx version="1.1"></gpx>'), 'gpx')).toBe('application/gpx+xml')
expect(accepted(Buffer.from('<html xmlns="http://www.w3.org/1999/xhtml"><script>1</script></html>'), 'gpx')).toBeNull()
})
it('refuse un document texte qui commence par une balise', () => {
expect(accepted(Buffer.from('<!doctype html><script>1</script>'), '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)
})
it('compte l essai avant la vérification : une rafale simultanée ne dépasse pas le plafond', () => {
resetTreeUnlockGuard()
const now = Date.now()
const reservations = Array.from({ length: 60 }, () => reserveTreeUnlockAttempt('tree-rafale', now))
expect(reservations.filter(Boolean)).toHaveLength(30)
expect(isTreeUnlockLocked('tree-rafale', now)).toBe(true)
})
it('rend la place d un essai réussi', () => {
resetTreeUnlockGuard()
const now = Date.now()
for (let i = 0; i < 29; i += 1) reserveTreeUnlockAttempt('tree-ok', now)
const release = reserveTreeUnlockAttempt('tree-ok', now)
expect(isTreeUnlockLocked('tree-ok', now)).toBe(true)
release?.()
expect(isTreeUnlockLocked('tree-ok', now)).toBe(false)
})
it('refuse au-delà de 20 comparaisons en attente au lieu d allonger la file', async () => {
let unblock: () => void = () => undefined
const gate = new Promise<void>((resolve) => { unblock = resolve })
const runs = Array.from({ length: 25 }, () => serializePasswordCheck(() => gate.then(() => true)))
expect(runs.filter((run) => run === null)).toHaveLength(5)
unblock()
await Promise.all(runs.filter(Boolean))
expect(serializePasswordCheck(async () => true)).not.toBeNull()
})
})
describe('vidéos : position effacée en temps linéaire', () => {
it('traite un faux MP4 de 2 Mo piégé pour l expression régulière en moins d une seconde', () => {
const body = Buffer.from('+1+1CRS'.repeat(300_000), 'latin1')
const head = Buffer.alloc(8)
head.writeUInt32BE(body.length + 8, 0)
head.write('moov', 4, 'latin1')
const started = Date.now()
scrubMp4Location(Buffer.concat([head, body]))
expect(Date.now() - started).toBeLessThan(1000)
})
it('efface toujours une position avec altitude et système de coordonnées', () => {
const position = '+48.8584+002.2945+035.000CRSWGS_84/'
const head = Buffer.alloc(8)
head.writeUInt32BE(position.length + 8, 0)
head.write('moov', 4, 'latin1')
const out = scrubMp4Location(Buffer.concat([head, Buffer.from(position, 'latin1')]))
expect(out.subarray(8).toString('latin1')).toBe('+00.0000+000.0000+000.000CRSWGS_00/')
})
})
describe('GIF', () => {
it('réencode un GIF et retire ses blocs de commentaire', async () => {
const gif = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#808080' } }).gif().toBuffer()
const comment = Buffer.concat([Buffer.from([0x21, 0xfe, 0x0b]), Buffer.from('secret-XMP!'), Buffer.from([0x00])])
const withComment = Buffer.concat([gif.subarray(0, gif.length - 1), comment, Buffer.from([0x3b])])
const out = await sanitizeImage(withComment, 'image/gif')
expect(out.subarray(0, 6).toString('latin1')).toMatch(/^GIF8[79]a$/)
expect(out.includes(Buffer.from('secret-XMP!'))).toBe(false)
})
})