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_ONLY → FOLDER_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+ policyowner_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 versvault_secure.users(id)avecNOT NULL- Migration
down()intentionnellement vide (sécurité production)
Recommandations¶
Suggestion (non-bloquante)¶
-
S-03 — Valider
document_typepar enum dans le DTO : Ajouter une validation@IsOptional() @IsEnum(DocumentType)surAddDocumentDtopour éviter les strings arbitraires dans les métadonnées d'audit. Impact : propreté des données. Priorité : basse. -
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'unSELECT FOR UPDATEsur la ligneusersdansPlanService.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.