Aller au contenu

PD-84 — Revue Sécurité

Date : 2026-02-24 Auditeur : Claude Opus 4.6 (mode pentester adversarial) Périmètre : Module src/modules/freemium/ — 30 fichiers source, 1 migration, 3 fichiers de test Spec : PD-84-specification.md v1.3.0 Code contracts : PD-84-code-contracts.yaml v1.1.0


Résumé

Critère Statut
Forbidden patterns ✅ Aucun trouvé
Injection SQL ✅ Requêtes paramétrées (TypeORM + advisory locks)
Auth/Authz ✅ OidcJwtAuthGuard + FolderOwnerGuard + RLS
Fuite données ✅ Aucun contenu probatoire exposé
Validation ✅ class-validator + enums stricts

Verdict : ✅ CONFORME


Audit des forbidden patterns

Pattern interdit (code-contracts.yaml) Recherché dans Trouvé
Table capability_state persistée (ECT-06) entities/, migrations/ ✅ Absent — CapabilityState est calculé à la volée
Cron/scheduler de reset quota services/, module ✅ Absent — aucun @Cron, @Interval, setTimeout périodique
Math.random() pour UUIDs Tous fichiers .ts ✅ Absent — crypto.randomUUID() utilisé (folder-document.service.ts:15,118-119)
Subquery dans index partiels migrations/ ✅ Absent — index simples sur (owner_user_id) et (owner_user_id, status)
AuditLogService.log() (bloquant) services/ ✅ Absent — tous les appels utilisent logAsync()
Branchement plan dans pipeline scellement folder-document.service.ts ✅ Absent — même chemin code FREE/PREMIUM (lignes 114-147)
CapabilityState persisté en base entities/, services/ ✅ Absent — CapabilityService.computeCapabilities() recalcule à chaque appel
Suppression dossier/document au downgrade plan.service.ts ✅ Absent — changePlan() ne touche ni dossiers ni documents
Endpoint suppression dossier controllers/ ✅ Absent — aucun @Delete dans le module
Endpoint suppression/modification document controllers/ ✅ Absent — seul @Post addDocument existe
Stack trace dans réponses d'erreur freemium.exception.ts ✅ Absent — structure { code, message, upgrade_cta? } uniquement
plan_type: 'business' accepté change-plan.dto.ts ✅ Absent — enum PlanType = FREE | PREMIUM uniquement
HttpException générique directement services/, controllers/ ✅ Absent — toujours FreemiumException (sauf NotImplementedException pour le stub PD-85 en PREMIUM, acceptable)
Contenu de preuve en clair dans DTOs dto/, entities/ ✅ Absent — ProbatoryFolder ne contient aucune colonne de contenu ; réponses = métadonnées uniquement

Tentatives de bypass

Attaque Cible Résultat Commentaire
Injection champ plan : PUT /account/plan { "plan_type": "BUSINESS" } PlanController ✅ Refusé — @IsEnum(PlanType) dans ChangePlanDto rejette les valeurs hors enum (400) Validation stricte par class-validator
Injection SQL via folderId : GET /folders/'; DROP TABLE-- FolderController ✅ Refusé — @Param('folderId', ParseUUIDPipe) valide le format UUID avant toute requête DB ParseUUIDPipe bloque les injections
Injection SQL via advisory lock : folderId forgé pour manipuler le hash FolderDocumentService ✅ Protégé — pg_advisory_xact_lock($1) utilise un paramètre positionnel ($1), pas une interpolation string Requête paramétrée
Cross-access user A sur dossier user B : JWT user A + folderId de user B FolderOwnerGuard ✅ Refusé — findOne({ id: folderId, ownerUserId: userId }) ne retourne rien → 404 Double vérification app + RLS
Bypass auth : requête sans JWT Tous endpoints ✅ Refusé — @UseGuards(OidcJwtAuthGuard) au niveau contrôleur Auth obligatoire
Bypass PlanStubGuard : PUT /account/plan en production PlanController ✅ Refusé — PlanStubGuard vérifie ENABLE_PLAN_STUB === 'true' → 404 si absent/false Triple verrou : guard + test TC-10-bis + CI check
Overflow display_name : chaîne > 255 caractères CreateFolderDto ✅ Refusé — @MaxLength(255) sur display_name Validation class-validator
Catégorie invalide : { "category": "ADMINISTRATIVE" } CreateFolderDto ✅ Refusé — @IsEnum(FolderCategory) ne reconnaît que B2C_EVIDENCE_MINOR → 400 Enum strict
Double clôture : POST /folders/{id}/close x2 FolderService.closeFolder ✅ 409 CONFLICT avec FOLDER_ALREADY_CLOSED — idempotent et sans effet de bord ECT-12 respecté
Ajout doc sur dossier clôturé FolderDocumentService ✅ Refusé — vérifie status === CLOSED_READ_ONLYFOLDER_CLOSED_READ_ONLY (422) Vérification avant quota
Manipulation sealed_document_count via body JSON Aucun endpoint ✅ Non exploitable — sealedDocumentCount n'est dans aucun DTO d'entrée, incrémenté côté service uniquement Champ non exposé en écriture
Race condition : 2 créations dossier concurrentes FolderService.createFolder ✅ Protégé — pg_advisory_xact_lock(hash(userId)) sérialise les transactions par utilisateur TC-LIM-01 valide sur 30 itérations
Race condition : 2 ajouts document concurrents FolderDocumentService.addDocument ✅ Protégé — pg_advisory_xact_lock(hash(folderId)) + FOR UPDATE sérialise TC-LIM-02 valide sur 30 itérations
Escalade via mass-assignment : { "status": "ACTIVE", "sealedDocumentCount": 999 } sur POST /folders FolderService.createFolder ✅ Non exploitable — createFolder() utilise des paramètres individuels (displayName, category), pas le body brut Pas de spread body → entity

Barrières primaires identifiées

Barrière Type Couverture
RLS PostgreSQL (owner_user_id = current_setting('app.current_user_id')) DB-level isolation Toute requête sur probatory_folders — isolation par tenant
OidcJwtAuthGuard Auth framework Tous les endpoints du module — pas d'accès sans token valide
Chiffrement client-side PD-60/PD-79 Crypto primaire Contenu des preuves jamais accessible côté serveur
pg_advisory_xact_lock Concurrence atomique Création dossier (hash userId) + ajout document (hash folderId)
CHECK constraint (sealed_document_count >= 0) DB-level integrity Empêche les valeurs négatives

Vulnérabilités identifiées

ID Description Gravité Fichier Analyse
S-01 PlanService.changePlan() hors transaction sérialisée : userRepository.findOne() + userRepository.save() sans SELECT FOR UPDATE ni advisory lock. Deux requêtes concurrentes pourraient lire le même plan et écraser mutuellement. MINEUR plan.service.ts:57-80 Vecteur : 2 appels PUT /account/plan simultanés pour le même user. Impact réel : L'endpoint est protégé par PlanStubGuard (environnement test/dev uniquement). En production, la transition sera un événement signé du PSP — une seule source. Mitigation : Le résultat final (FREE ou PREMIUM) est déterministe par la valeur du dernier write. Classement : MINEUR (endpoint non exposé en production, résultat convergent).
S-02 Audit non-bloquant : perte possible d'événements : logAsync() est fire-and-forget. Si BullMQ et PD-31 sont simultanément indisponibles, les événements d'audit (quota refusé, clôture, changement plan) pourraient être perdus. MINEUR (écart documenté) folder.service.ts:75, plan.service.ts:106 Cadrage spec : SEC-84-03/ECT-v2-03 stipule explicitement que « l'indisponibilité PD-31 ne DOIT PAS bloquer les opérations métier ». INV-84-03 prime : la constitution de dossier n'est jamais bloquée. La résilience audit est une responsabilité PD-31. Classement : MINEUR — choix architectural documenté.
S-03 document_type non validé par enum : AddDocumentDto dans folder-document.controller.ts:21-23 accepte document_type?: string sans validation. Un payload { "document_type": "<script>alert(1)</script>" } passerait et serait stocké dans les métadonnées d'audit. MINEUR folder-document.controller.ts:21-23 Vecteur : Payload avec string arbitraire dans document_type. Impact : Le champ est utilisé uniquement dans les métadonnées d'audit (pas de rendu HTML). Pas de XSS car le backend est une API REST JSON. Le document réel est géré par PD-60 DepositService. Mitigation : Le champ a un fallback 'OTHER_SUPPORTED' (ligne 44). Classement : MINEUR — pas de vecteur d'exploitation réel, le champ pourrait bénéficier d'une validation enum côté DTO pour la propreté des données d'audit.

Analyse détaillée par composant

1. Authentification et autorisation

OidcJwtAuthGuard : Appliqué au niveau @Controller sur les 5 contrôleurs. Aucun endpoint n'est accessible sans JWT valide.

FolderOwnerGuard : - Vérifie ownerUserId au niveau applicatif (défense en profondeur) - Requête : findOne({ id: folderId, ownerUserId: userId }) — pas de jointure ni de sub-select - Retourne 404 (pas 403) — ne révèle pas l'existence du dossier à un tiers - Cas edge : si folderId absent des params, le guard laisse passer (ligne 35-37) — ceci est correct car les routes sans folderId (POST /folders, GET /folders) n'ont pas besoin de ce guard

PlanStubGuard : - Vérifie ENABLE_PLAN_STUB === 'true' via ConfigService (pas d'accès direct à process.env) - Retourne 404 (invisible) quand désactivé — ECT-v2-05 respecté - Script CI check-env-production.sh : vérifie .env.production et .env.prod — bloque le pipeline si ENABLE_PLAN_STUB=true trouvé

2. Injection SQL

Toutes les requêtes passent par TypeORM (ORM paramétrés) : - manager.findOne(), manager.count(), manager.save() — requêtes paramétrées - manager.query('SELECT pg_advisory_xact_lock($1)', [lockKey]) — paramètre positionnel - ParseUUIDPipe sur tous les folderId — format UUID validé avant toute requête

La migration utilise des chaînes DDL statiques (pas d'interpolation dynamique).

3. Gestion des quotas et atomicité

Sérialisation : - Création dossier : pg_advisory_xact_lock(hash(userId)) → sérialise les créations par utilisateur - Ajout document : pg_advisory_xact_lock(hash(folderId)) + pessimistic_write → double verrou

Hash stable : La fonction hashToInt() (DJB2) produit un entier 32-bit stable pour un même input. L'opérateur | 0 force le résultat en 32-bit signé, compatible avec pg_advisory_xact_lock(bigint).

Séquence (folder-document.service.ts) : 1. Advisory lock sur folderId 2. findOne avec pessimistic_write (SELECT FOR UPDATE) 3. Vérification status (clôturé ?) 4. Vérification quota (plan FREE ?) 5. Incrément sealedDocumentCount 6. save dans la même transaction

→ Séquence correcte : le lock empêche le double-spend.

4. Protection du endpoint plan stub en production

Triple verrou vérifié : 1. Runtime : PlanStubGuard → 404 si ENABLE_PLAN_STUB !== 'true' 2. Test : TC-10-bis valide le guard avec env var false 3. CI : scripts/check-env-production.sh scanne les fichiers .env.production*

Le script CI vérifie APP_ENV=production et NODE_ENV=production. Limitation mineure : il ne scanne pas les .env.staging ou d'autres variantes, mais le guard runtime couvre ce cas.

5. Fuite de données

Entité ProbatoryFolder : aucune colonne de contenu (pas de file_content, encrypted_data, etc.). Seules les métadonnées sont stockées (nom, catégorie, statut, compteur).

FolderResponseDto : expose uniquement folder_id, display_name, category, status, sealed_document_count, created_at, closed_at, capabilities, export_cta. Aucun champ de contenu probatoire.

SealedDocumentResult : expose document_id, folder_id, document_type, sealed_at, probatory_seal_ref, integrity_state, anchoring_state. Ce sont des métadonnées de scellement, pas du contenu.

6. Validation des entrées

Endpoint DTO Validations
POST /folders CreateFolderDto display_name: @IsString, @IsNotEmpty, @MaxLength(255). category: @IsEnum(FolderCategory)
PUT /account/plan ChangePlanDto plan_type: @IsEnum(PlanType) — refuse 'BUSINESS', null, etc.
POST /folders/:id/documents AddDocumentDto (inline) document_type?: string — pas de validation stricte (voir S-03)
POST /folders/:id/close (pas de body) folderId: ParseUUIDPipe
GET /folders/:id (pas de body) folderId: ParseUUIDPipe

7. Migration et schéma DB

  • sealed_document_count : INTEGER NOT NULL DEFAULT 0 CHECK (sealed_document_count >= 0) — empêche les valeurs négatives
  • RLS activé : ENABLE ROW LEVEL SECURITY + policy owner_user_id = current_setting('app.current_user_id')::uuid
  • Enum PostgreSQL pour plan_type, folder_status_type, folder_category_type, account_role_type — empêche les valeurs arbitraires au niveau DB
  • owner_user_id : FK vers vault_secure.users(id) avec NOT NULL
  • Migration down() intentionnellement vide (sécurité production)

Recommandations

Suggestion (non-bloquante)

  1. S-03 — Valider document_type par enum dans le DTO : Ajouter une validation @IsOptional() @IsEnum(DocumentType) sur AddDocumentDto pour éviter les strings arbitraires dans les métadonnées d'audit. Impact : propreté des données. Priorité : basse.

  2. S-01 — Transaction sérialisée pour changePlan : Bien que l'endpoint soit protégé par PlanStubGuard (test/dev uniquement), la version production (événement PSP) pourrait bénéficier d'un SELECT FOR UPDATE sur la ligne users dans PlanService.changePlan(). Priorité : basse — à traiter lors de l'intégration PSP réelle.


Conclusion

L'implémentation PD-84 respecte les invariants de sécurité. Les barrières primaires (RLS, auth JWT, chiffrement client-side PD-60, advisory locks) sont correctement en place. Les 3 findings identifiés sont classés MINEUR : l'un est un choix architectural documenté (audit non-bloquant), les deux autres sont des améliorations de robustesse sans vecteur d'attaque exploitable dans le contexte actuel.

Le triple verrou sur PUT /account/plan (guard runtime + test contractuel + check CI) est effectif pour empêcher l'exposition en production.

Aucune vulnérabilité CRITIQUE ou MAJEURE identifiée. Le code est conforme aux forbidden patterns des code-contracts et aux invariants de sécurité SEC-84-01 à SEC-84-05.