Accès : annotations protégées, jeton médias, vraie adresse des visiteurs

Les annotations se lisaient avec n'importe quel compte, et une annotation photo enregistrait l'adresse de l'image avec la session de son auteur. Accès vérifié, adresse retirée à l'écriture comme à la lecture. Les images portent un jeton de lecture des médias (12 h, un arbre) au lieu de la session du compte. trustProxy ne croit plus que nginx (TRUST_PROXY), l'API peut n'écouter que la boucle locale (HOST), plafond de 30 échecs par arbre sur le mot de passe de partage, changer ce mot de passe coupe les comptes rattachés, connexion à durée constante, secrets d'exemple refusés en production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-30 17:57:26 +02:00
1 parent 78445589de
commit ced84333db
15 files changed
+308 -17

No files matched your search

+8 -1
View File
@@ -13,7 +13,7 @@ POSTGRES_DB=parenthese
POSTGRES_PASSWORD=
# ── API ───────────────────────────────────────────────────────────────────────
# À remplir, 16 caractères minimum. Générer avec :
# À remplir, 32 caractères conseillés. Générer avec :
# openssl rand -base64 48
# ou : node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
# L'API refuse de démarrer en production avec la valeur de développement.
@@ -26,6 +26,13 @@ JWT_TREE_ACCESS_EXPIRES_IN=24h
# Derrière un reverse proxy HTTPS : https://arbre.votre-domaine.fr
# Plusieurs adresses : séparées par des virgules.
CORS_ORIGIN=http://localhost
# Proxys dont l'API croit l'en-tête X-Forwarded-For (adresse des visiteurs, limites d'essais).
# Par défaut, le nginx du conteneur web (réseau Docker privé). Si vous ajoutez un reverse proxy
# devant, ajoutez son adresse, par exemple loopback,uniquelocal,203.0.113.10
# TRUST_PROXY=loopback,uniquelocal
# Place disque par arbre (Mo), et part réservée aux envois de la famille en attente de relecture.
# MEDIA_QUOTA_PER_TREE_MB=5120
# MEDIA_PENDING_QUOTA_PER_TREE_MB=500
# ── Web ───────────────────────────────────────────────────────────────────────
# Port publié sur la machine hôte (80 = http://localhost sans préciser de port).
+4
View File
@@ -4,6 +4,10 @@
NODE_ENV=production
PORT=4000
# nginx sur la même machine : l'API n'écoute que la boucle locale, et ne croit que nginx pour
# l'adresse des visiteurs (limites d'essais sur les mots de passe).
HOST=127.0.0.1
TRUST_PROXY=loopback
# Base PostgreSQL dédiée. L'API refuse de démarrer en production avec l'URL de développement.
DATABASE_URL="postgresql://UTILISATEUR:MOT_DE_PASSE@localhost:5432/parenthese"
+24 -3
View File
@@ -18,14 +18,34 @@ import { treeRoutes } from './routes/trees.js'
import { visitRoutes } from './routes/visits.js'
import type { AnyJwtPayload } from './types/auth.js'
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête) : jamais dans les journaux
export function redactUrl(url: string | undefined): string | undefined {
return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]')
}
export function createApp() {
const app = Fastify({
logger: env.NODE_ENV !== 'test',
logger: env.NODE_ENV === 'test'
? false
: {
serializers: {
req(request) {
return {
method: request.method,
url: redactUrl(request.url),
host: request.host,
remoteAddress: request.ip,
}
},
},
},
bodyLimit: 12 * 1024 * 1024,
// Accept both `/path` et `/path/` to avoid 301 → 400 issues via nginx
routerOptions: { ignoreTrailingSlash: true },
// Trust X-Forwarded-For from Nginx proxy so rate limiting uses real client IPs
trustProxy: true,
// Seuls les proxys connus (nginx) sont crus : l'adresse du client est la dernière qu'ils ont ajoutée à
// X-Forwarded-For. Faire confiance à tout l'en-tête laissait le client choisir son adresse et contourner
// les limites d'essais (voir TRUST_PROXY dans config/env.ts).
trustProxy: env.TRUST_PROXY,
})
app.decorate('prisma', prisma)
@@ -99,6 +119,7 @@ export function createApp() {
kind: 'tree_access',
treeId: payload.treeId,
role: payload.role,
accessVersion: payload.accessVersion,
}
}
} catch {
+31 -2
View File
@@ -6,9 +6,31 @@ loadDotenv()
const DEV_DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
const DEV_JWT_SECRET = 'dev-secret-change-me-please'
// Valeurs d'exemple publiées dans le dépôt : une instance qui les garde a un secret connu de tous
const PUBLISHED_PLACEHOLDER_SECRETS = [
DEV_JWT_SECRET,
'change-me-in-production',
'changeme',
]
// Adresses des proxys de confiance devant l'API (liste séparée par des virgules, mots-clés de proxy-addr :
// loopback, uniquelocal, linklocal, ou IP/CIDR). Par défaut : nginx sur la même machine (pm2) ou dans le
// réseau Docker (compose). L'adresse du client est la dernière entrée de X-Forwarded-For ajoutée par un
// proxy de confiance. `true` lirait l'adresse dans un en-tête que le client écrit lui-même, et Fastify
// ignore un nombre de sauts : ni l'un ni l'autre n'est accepté.
function parseTrustProxy(value: string): boolean | string {
if (value === 'false') return false
if (value === 'true' || /^\d+$/.test(value)) {
throw new Error('TRUST_PROXY must list the proxy addresses (e.g. "loopback,uniquelocal"), not true or a hop count')
}
return value
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(4000),
HOST: z.string().min(1).default('0.0.0.0'),
TRUST_PROXY: z.string().default('loopback,uniquelocal').transform(parseTrustProxy),
DATABASE_URL: z.string().min(1).default(DEV_DATABASE_URL),
JWT_SECRET: z.string().min(16).default(DEV_JWT_SECRET),
JWT_EXPIRES_IN: z.string().default('7d'),
@@ -18,13 +40,20 @@ const envSchema = z.object({
.default('http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176')
.transform((value) => value.split(',').map((origin) => origin.trim()).filter(Boolean)),
MEDIA_STORAGE_PATH: z.string().default('./storage/media'),
// Place disque par arbre, et part réservée aux envois de la famille en attente de relecture
MEDIA_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(5120),
MEDIA_PENDING_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(500),
})
export const env = envSchema.parse(process.env)
if (env.NODE_ENV === 'production') {
if (env.JWT_SECRET === DEV_JWT_SECRET) {
throw new Error('JWT_SECRET must be overridden in production')
if (PUBLISHED_PLACEHOLDER_SECRETS.includes(env.JWT_SECRET)) {
throw new Error('JWT_SECRET is a published example value: generate one with `openssl rand -base64 48`')
}
if (env.JWT_SECRET.length < 32) {
console.warn('JWT_SECRET is shorter than 32 characters: generate a longer one with `openssl rand -base64 48`')
}
if (env.DATABASE_URL === DEV_DATABASE_URL) {
+45
View File
@@ -0,0 +1,45 @@
// Annotation photo : le client enregistrait `url`, l'adresse de l'image AVEC la session de son auteur
// (?token=…), lisible par tous ceux qui ouvrent l'arbre. Le serveur ne garde que `photoPath` et le
// client reconstruit l'adresse au chargement. À la lecture, les anciennes valeurs sont nettoyées aussi.
function parsePhotoContent(content: string): Record<string, unknown> | null {
if (!content.startsWith('{')) return null
try {
const parsed = JSON.parse(content)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && ('photoPath' in parsed || 'url' in parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// pas du JSON : texte libre
}
return null
}
export class InvalidAnnotationContentError extends Error {}
// Écriture : photoPath obligatoirement une photo de cet arbre, url retirée
export function sanitizeAnnotationContentForWrite(content: string, treeId: string): string {
const parsed = parsePhotoContent(content)
if (!parsed) return content
const photoPath = parsed.photoPath
const expectedPrefix = `/trees/${treeId}/annotation-photos/`
if (
typeof photoPath !== 'string' ||
!photoPath.startsWith(expectedPrefix) ||
!/^[A-Za-z0-9-]+$/.test(photoPath.slice(expectedPrefix.length))
) {
throw new InvalidAnnotationContentError('invalid_annotation_photo_path')
}
const { url: _url, ...rest } = parsed
return JSON.stringify(rest)
}
// Lecture : jamais d'adresse enregistrée renvoyée
export function sanitizeAnnotationContentForRead(content: string): string {
const parsed = parsePhotoContent(content)
if (!parsed || !('url' in parsed)) return content
const { url: _url, ...rest } = parsed
return JSON.stringify(rest)
}
+3 -1
View File
@@ -16,8 +16,10 @@ export async function matchSharePassword(
hashes: string[],
verify: (password: string, hash: string) => Promise<boolean>,
): Promise<string | null> {
// Un mot de passe unique est rangé dans les deux colonnes avec le même hash : une seule comparaison bcrypt
const distinctHashes = [...new Set(hashes)]
for (const candidate of sharePasswordVariants(input)) {
for (const hash of hashes) {
for (const hash of distinctHashes) {
if (await verify(candidate, hash)) return candidate
}
}
+81
View File
@@ -0,0 +1,81 @@
import type { FastifyInstance } from 'fastify'
import type { Actor, MediaJwtPayload } from '../types/auth.js'
// Lecture d'un arbre : membre, compte rattaché par le mot de passe de partage, ou jeton d'arbre valide
export async function canReadTree(app: FastifyInstance, treeId: string, actor: Actor): Promise<boolean> {
if (!actor) {
return false
}
if (actor.kind === 'user') {
const membership = await app.prisma.treeMembership.findUnique({
where: { treeId_userId: { treeId, userId: actor.userId } },
include: { tree: { select: { deletedAt: true } } },
})
if (membership && !membership.tree.deletedAt) {
return true
}
const shared = await app.prisma.userTreeAccess.findUnique({
where: { treeId_userId: { treeId, userId: actor.userId } },
include: { tree: { select: { deletedAt: true } } },
})
return Boolean(shared && !shared.tree.deletedAt)
}
if (actor.treeId !== treeId) {
return false
}
// Le jeton d'arbre a déjà été confronté à la version des mots de passe dans le preHandler
const tree = await app.prisma.tree.findFirst({ where: { id: treeId, deletedAt: null }, select: { id: true } })
return Boolean(tree)
}
// Les images sont chargées par <img src>, qui ne peut pas envoyer d'en-tête : leur adresse porte un jeton.
// Ce jeton ne sert qu'à lire les médias d'un arbre, jamais la session du compte (qui finissait dans les
// journaux, l'historique et les liens copiés).
export const MEDIA_TOKEN_TTL = '12h'
export function signMediaToken(app: FastifyInstance, treeId: string, actor: NonNullable<Actor>): string {
const payload: MediaJwtPayload = {
kind: 'media',
sub: `media:${treeId}`,
treeId,
...(actor.kind === 'user' ? { userId: actor.userId } : { accessVersion: actor.accessVersion }),
}
return app.jwt.sign(payload, { expiresIn: MEDIA_TOKEN_TTL })
}
export async function canReadTreeWithMediaToken(app: FastifyInstance, treeId: string, token: string): Promise<boolean> {
let payload: { kind?: string; treeId?: string; userId?: string; accessVersion?: number }
try {
payload = await app.jwt.verify(token)
} catch {
return false
}
if (payload.kind !== 'media' || payload.treeId !== treeId) {
return false
}
// Émis pour un compte : ses droits sont relus, un accès retiré coupe aussi les médias
if (payload.userId) {
const user = await app.prisma.user.count({ where: { id: payload.userId } })
if (user === 0) {
return false
}
return canReadTree(app, treeId, { kind: 'user', userId: payload.userId, email: '' })
}
// Émis pour un mot de passe de partage : invalide dès que le mot de passe change
const accessState = await app.prisma.treeAccessPasswords.findUnique({
where: { treeId },
select: { updatedAt: true, tree: { select: { deletedAt: true } } },
})
if (!accessState || accessState.tree.deletedAt) {
return false
}
return accessState.updatedAt.getTime() === payload.accessVersion
}
+33
View File
@@ -0,0 +1,33 @@
// Plafond d'échecs par arbre, en plus de la limite par adresse : un mot de passe de partage est choisi par
// une famille (« 123soleil »), un dictionnaire envoyé depuis beaucoup d'adresses finirait par le trouver.
// Au-delà du plafond, l'arbre refuse les essais jusqu'à la fin de la fenêtre, même le bon mot de passe.
// En mémoire : l'API tourne en un seul processus (pm2 fork, un conteneur).
const WINDOW_MS = 15 * 60 * 1000
const MAX_FAILURES = 30
const failuresByTree = new Map<string, number[]>()
function recentFailures(treeId: string, now: number): number[] {
const recent = (failuresByTree.get(treeId) ?? []).filter((at) => now - at < WINDOW_MS)
if (recent.length > 0) {
failuresByTree.set(treeId, recent)
} else {
failuresByTree.delete(treeId)
}
return recent
}
export function isTreeUnlockLocked(treeId: string, now = Date.now()): boolean {
return recentFailures(treeId, now).length >= MAX_FAILURES
}
export function recordTreeUnlockFailure(treeId: string, now = Date.now()): void {
const recent = recentFailures(treeId, now)
recent.push(now)
failuresByTree.set(treeId, recent)
}
export function resetTreeUnlockGuard(): void {
failuresByTree.clear()
}
+40 -7
View File
@@ -2,6 +2,12 @@ import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest
import { Prisma } from '@prisma/client'
import { z } from 'zod'
import {
InvalidAnnotationContentError,
sanitizeAnnotationContentForRead,
sanitizeAnnotationContentForWrite,
} from '../lib/annotation-content.js'
import { canReadTree } from '../lib/tree-access.js'
import type { MembershipRole } from '../types/auth.js'
const treeIdParamSchema = z.object({
@@ -59,6 +65,16 @@ function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: st
}
}
// null = chemin de photo refusé (voir lib/annotation-content.ts)
function cleanContent(content: string, treeId: string): string | null {
try {
return sanitizeAnnotationContentForWrite(content, treeId)
} catch (error) {
if (error instanceof InvalidAnnotationContentError) return null
throw error
}
}
function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
@@ -97,13 +113,16 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
const membership = await getAdminMembership(app, user.userId, params.data.id)
if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' })
const content = cleanContent(payload.data.content, params.data.id)
if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
const annotation = await app.prisma.annotation.create({
data: {
treeId: params.data.id,
type: payload.data.type,
x: payload.data.x,
y: payload.data.y,
content: payload.data.content,
content,
style: (payload.data.style ?? {}) as Prisma.InputJsonValue,
zIndex: payload.data.zIndex ?? 0,
createdBy: user.userId,
@@ -131,6 +150,7 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
const annotations = await app.prisma.annotation.findMany({
where: {
@@ -140,7 +160,9 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
})
return reply.send({ annotations })
return reply.send({
annotations: annotations.map((annotation) => ({ ...annotation, content: sanitizeAnnotationContentForRead(annotation.content) })),
})
})
// UPDATE annotation
@@ -160,7 +182,11 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
const data: Record<string, unknown> = {}
if (payload.data.x !== undefined) data.x = payload.data.x
if (payload.data.y !== undefined) data.y = payload.data.y
if (payload.data.content !== undefined) data.content = payload.data.content
if (payload.data.content !== undefined) {
const content = cleanContent(payload.data.content, params.data.id)
if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
data.content = content
}
if (payload.data.style !== undefined) data.style = payload.data.style
if (payload.data.zIndex !== undefined) data.zIndex = payload.data.zIndex
@@ -249,15 +275,22 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
const treeId = params.data.id
const results = { created: 0, updated: 0, deleted: 0 }
// Tout est vérifié avant la première écriture
const createContents = payload.data.create.map((item) => cleanContent(item.content, treeId))
const updateContents = payload.data.update.map((item) => (item.content === undefined ? undefined : cleanContent(item.content, treeId)))
if (createContents.includes(null) || updateContents.includes(null)) {
return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
}
// Creates
for (const item of payload.data.create) {
for (const [index, item] of payload.data.create.entries()) {
await app.prisma.annotation.create({
data: {
treeId,
type: item.type,
x: item.x,
y: item.y,
content: item.content,
content: createContents[index] as string,
style: (item.style ?? {}) as Prisma.InputJsonValue,
zIndex: item.zIndex ?? 0,
createdBy: user.userId,
@@ -267,11 +300,11 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
}
// Updates
for (const item of payload.data.update) {
for (const [index, item] of payload.data.update.entries()) {
const data: Record<string, unknown> = {}
if (item.x !== undefined) data.x = item.x
if (item.y !== undefined) data.y = item.y
if (item.content !== undefined) data.content = item.content
if (item.content !== undefined) data.content = updateContents[index]
if (item.style !== undefined) data.style = item.style
if (item.zIndex !== undefined) data.zIndex = item.zIndex
+9
View File
@@ -8,6 +8,13 @@ import { revokeUserToken } from '../lib/token-revocation.js'
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
import type { AnyJwtPayload } from '../types/auth.js'
// Hash de comparaison pour une adresse inconnue, calculé une fois au premier besoin
let dummyHash: Promise<string> | null = null
function getDummyHash(): Promise<string> {
dummyHash ??= hashPassword(randomUUID())
return dummyHash
}
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
@@ -152,6 +159,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
})
if (!user) {
// Même durée qu'un mauvais mot de passe : la réponse ne dit pas si l'adresse a un compte
await verifyPassword(parsed.data.password, await getDummyHash())
return reply.code(401).send({ error: 'invalid_credentials' })
}
+3
View File
@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { signMediaToken } from '../lib/tree-access.js'
import { recordTreeVisit, type VisitAccessKind } from '../lib/tree-visits.js'
import { buildGraphPayload } from '../utils/graph-payload.js'
@@ -143,6 +144,8 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
treeId,
rootPersonId,
graph,
// Pour l'adresse des images (<img src> n'envoie pas la session) : lecture des médias de cet arbre seulement
mediaToken: signMediaToken(app, treeId, actor),
})
})
}
+12
View File
@@ -7,6 +7,7 @@ import { hashPassword, verifyPassword } from '../lib/auth.js'
import { matchSharePassword } from '../lib/password-variants.js'
import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js'
import { purgeTrees } from '../lib/tree-purge.js'
import { isTreeUnlockLocked, recordTreeUnlockFailure } from '../lib/unlock-guard.js'
import type { MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
@@ -694,6 +695,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'tree_not_found' })
}
if (isTreeUnlockLocked(params.data.id)) {
return reply.code(429).send({ error: 'too_many_attempts' })
}
// Un seul mot de passe de partage (25/09/2026) : regarder et proposer. Les deux colonnes restent,
// les arbres créés avant ont encore deux mots de passe différents, les deux ouvrent en contributeur.
// Apostrophe typographique ou espace finale ajoutées par un clavier de téléphone : acceptées (password-variants.ts)
@@ -704,6 +709,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
)
if (!matches) {
recordTreeUnlockFailure(params.data.id)
return reply.code(401).send({ error: 'invalid_password' })
}
@@ -972,6 +978,12 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data,
})
// Changer le mot de passe met dehors ceux qui l'avaient : les jetons d'arbre tombent avec la version,
// les comptes rattachés par ce mot de passe perdent aussi leur accès (ils le retapent s'ils ont le nouveau)
if (Object.keys(data).length > 0) {
await app.prisma.userTreeAccess.deleteMany({ where: { treeId: params.data.id } })
}
// Le mot de passe lisible suit toujours celui en vigueur ; deux mots de passe distincts = plus de valeur unique
if (payload.data.password) {
const passwordEnc = encryptSharePassword(payload.data.password)
+1 -1
View File
@@ -7,7 +7,7 @@ async function bootstrap() {
try {
await app.listen({
port: env.PORT,
host: '0.0.0.0',
host: env.HOST,
})
} catch (error) {
app.log.error(error)
+11 -1
View File
@@ -17,7 +17,16 @@ export type TreeAccessJwtPayload = {
accessVersion: number
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload
// Lecture seule des médias d'un arbre, dans l'adresse des images (voir lib/tree-access.ts)
export type MediaJwtPayload = {
kind: 'media'
sub: string
treeId: string
userId?: string
accessVersion?: number
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload
export type UserActor = {
kind: 'user'
@@ -29,6 +38,7 @@ export type TreeAccessActor = {
kind: 'tree_access'
treeId: string
role: TreeAccessRole
accessVersion: number
}
export type Actor = UserActor | TreeAccessActor | null
+3 -1
View File
@@ -1,5 +1,7 @@
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
export type GraphPayload = {
persons: Array<{
id: string
@@ -122,7 +124,7 @@ export function buildGraphPayload(
type: ann.type,
x: ann.x,
y: ann.y,
content: ann.content,
content: sanitizeAnnotationContentForRead(ann.content),
style: ann.style,
zIndex: ann.zIndex,
createdBy: ann.createdBy,