PD-238 — Revue de sécurité¶
Date : 2026-02-06 Auteur : agent-adversarial (red team) Scope : MFA Management — Keycloak Admin API integration Commit de base : dev branch, post-PD-238 implementation
Résumé exécutif¶
- Statut global : PASS_WITH_OBSERVATIONS
- Nombre de vulnérabilités critiques : 0
- Nombre de vulnérabilités majeures : 2
- Nombre d'observations mineures : 4
L'implémentation PD-238 présente une posture de sécurité globalement solide. Les trois invariants audités (INV-238-09, INV-238-10, INV-238-02) sont conformes. Deux points majeurs méritent attention : le fail-open du rate limiter Redis et l'implémentation custom du constant-time comparison. Quatre observations mineures sont documentées.
Vérification des invariants¶
INV-238-09 : Secrets jamais loggés¶
- Statut : CONFORME
Méthodologie : Recherche exhaustive par grep de tous les appels console.log, logger.log, logger.debug, logger.info, logger.warn, logger.error dans le périmètre audité, croisée avec les mots-clés secret, qrCodeUri, recoveryCodes, password, token.
Observations :
-
Aucun
console.logtrouvé dans l'ensemble du périmètresrc/modules/auth/mfa/. -
Logs service keycloak-admin.service.ts — Tous les messages de log utilisent des messages génériques sans données sensibles :
- Ligne 239 :
Getting MFA status for user(pas de userId loggé) - Ligne 288 :
Initializing TOTP for user(pas de secret) - Ligne 336 :
Verifying TOTP for user(pas de code TOTP) - Ligne 379 :
Disabling MFA for user - Ligne 415 :
Regenerating recovery codes for user -
Lignes 296-297 : Commentaires explicites
// INV-238-09: Do NOT log the response containing secrets -
Logs service mfa-management.service.ts — Même rigueur :
- Aucun log ne contient
secret,qrCodeUri,recoveryCodes, ni le code TOTP -
Ligne 178 :
No TOTP session found for user, init may have expired— correct, pas de fuite -
Logs reauth.service.ts — Le mot
passwordn'apparaît jamais dans aucun appel logger : - Ligne 75 :
Reauth attempt for userId=${userId}— seul userId loggé - Ligne 93 :
Reauth failed: Invalid password userId=${userId}— mentionne le mot "password" dans le message descriptif, mais ne logge pas la valeur du password. Acceptable mais voir OBS-03. -
Ligne 185 :
Password verification error: ${error.message}— logge le message d'erreur, qui ne devrait pas contenir le password (provient d'opérations crypto). Acceptable mais surveiller les messages d'erreur des bibliothèques crypto. -
Logs reauth-token.guard.ts — Les messages de log concernant les tokens sont descriptifs :
Reauth token absent,Reauth token signature invalid or expired, etc.-
Aucune valeur de token n'est loggée.
-
Logs mfa-exception.filter.ts — Ligne 57 : logge
exception.codeetexception.messagequi sont des constantes définies dansmfa-management.errors.ts, jamais des secrets. -
DTOs avec
@Exclude()— Protection contre la sérialisation accidentelle : TotpInitResponseDto: class-level@Exclude(), champs sensibles avec@Expose()expliciteTotpVerifyResponseDto: class-level@Exclude(),recoveryCodesavec@Expose()expliciteRecoveryRegenerateResponseDto: class-level@Exclude(),recoveryCodesavec@Expose()explicite
Note : L'annotation @Exclude() au niveau de la classe avec @Expose() sélectif fonctionne uniquement si ClassSerializerInterceptor est activé globalement ou au niveau du controller. Aucun ClassSerializerInterceptor n'a été trouvé dans le codebase (ni dans main.ts, ni comme APP_INTERCEPTOR). Cela signifie que les annotations @Exclude()/@Expose() sont documentaires mais ne sont pas fonctionnellement actives. Voir VUL-02.
- ReauthRequestDto — Le champ
passwordn'a pas de@Exclude(). Ce DTO est un DTO d'entrée (request body), pas de réponse, donc le risque est limité. Cependant, si un intercepteur de logging serialise le body de la requête, le password serait exposé. Voir OBS-01.
Fichiers audités : - src/modules/auth/mfa/services/keycloak-admin.service.ts - src/modules/auth/mfa/services/mfa-management.service.ts - src/modules/auth/mfa/controllers/mfa-management.controller.ts - src/modules/auth/services/reauth.service.ts - src/modules/auth/controllers/reauth.controller.ts - src/modules/auth/mfa/guards/reauth-token.guard.ts - src/modules/auth/guards/mfa-rate-limit.guard.ts - src/modules/auth/mfa/errors/mfa-management.errors.ts - src/modules/auth/mfa/filters/mfa-exception.filter.ts - src/modules/auth/mfa/dto/*.ts - src/modules/auth/dto/reauth.dto.ts
INV-238-10 : Pas de stockage local¶
- Statut : CONFORME
Méthodologie : Recherche de toute opération de persistance (TypeORM .save(), .insert(), .create(), .update(), repository) liée aux mots-clés secret, totp, recovery, qrCode. Analyse des opérations Redis.
Observations :
-
Aucune insertion en base de données de secrets TOTP, qrCodeUri, ou codes de récupération. Les services MFA n'injectent aucun
RepositoryTypeORM pour des entités liées au MFA. La seule injection TypeORM dansreauth.service.tsest pourUser(vérification du mot de passe), pas pour stocker des secrets MFA. -
Redis — sessionId uniquement :
mfa-management.service.ts:320:redis.set(key, sessionId, 'EX', TOTP_SESSION_TTL_SECONDS)- Le TTL est de 300 secondes (5 minutes) — CONFORME
- La clé stockée est
mfa:totp:session:{userId}avec pour valeur lesessionIdKeycloak uniquement - Le secret TOTP, le qrCodeUri et les recovery codes ne sont jamais stockés dans Redis
-
Le sessionId est supprimé après activation réussie (
deleteSessionId, ligne 349) -
Pattern pass-through : Le service
keycloak-admin.service.tsretourne les secrets directement depuis Keycloak sans les persister : initTotp(): retourne le TotpInitResult en mémoire transitoireverifyAndActivateTotp(): retourne les recoveryCodes en mémoire transitoireregenerateRecoveryCodes(): retourne les codes en mémoire transitoire-
Aucune variable de classe ne stocke ces secrets
-
Token admin Keycloak : Le
accessTokenest stocké en mémoire (propriété de l'instance). C'est un token de service account, pas un secret MFA utilisateur. Acceptable car il s'agit d'un cache applicatif standard pour l'authentification service-to-service.
Fichiers audités : - src/modules/auth/mfa/services/keycloak-admin.service.ts - src/modules/auth/mfa/services/mfa-management.service.ts - src/modules/auth/mfa/controllers/mfa-management.controller.ts
INV-238-02 : Pas d'accès croisé¶
- Statut : CONFORME
Méthodologie : Analyse de tous les endpoints pour vérifier que le userId est systématiquement extrait du JWT (sub claim) et jamais d'un paramètre client (URL param, query param, body).
Observations :
- Controller MFA (
mfa-management.controller.ts) — Tous les 5 endpoints utilisent@CurrentUser() user: JwtPayloadet passentuser.subau service : getStatus():user.sub→ ligne 84initTotp():user.sub→ ligne 117verifyTotp():user.sub→ ligne 153disable():user.sub→ ligne 193-
regenerateRecoveryCodes():user.sub→ ligne 235 -
Controller Reauth (
reauth.controller.ts) — Utilise@CurrentUser() user: JwtPayloadet passeuser.sub: -
reauth():user.sub→ ligne 75 -
Aucun paramètre URL
userIdoukeycloakUserId— Recherche exhaustive de@Paramet@Query.*userIddans les controllers : aucun résultat. -
Décorateur
@CurrentUser()— Extraitrequest.userqui est peuplé parOidcJwtAuthGuardà partir du JWT validé. Le claimsubprovient exclusivement du token JWT signé, impossible à falsifier sans la clé de signature. -
Guard
ReauthTokenGuard— Vérifie que lesubdu reauth token correspond ausubdu JWT principal (ligne 81 :payload.sub !== principalUserId). Cela empêche un attaquant qui obtiendrait un reauth token d'un autre utilisateur de l'utiliser. -
Services — Les méthodes de service prennent un
userId: stringen paramètre, qui provient toujours deuser.subvia le controller. Aucun chemin alternatif n'existe.
Fichiers audités : - src/modules/auth/mfa/controllers/mfa-management.controller.ts - src/modules/auth/controllers/reauth.controller.ts - src/modules/auth/decorators/current-user.decorator.ts - src/modules/auth/mfa/guards/reauth-token.guard.ts
Analyse OWASP Top 10¶
A01:2021 — Broken Access Control¶
Statut : Bien couvert
- Tous les endpoints MFA sont protégés par
OidcJwtAuthGuard(au niveau controller class) - Les opérations sensibles (disable, regenerate) requièrent un
ReauthTokenGuardadditionnel - Le userId est toujours extrait du JWT, jamais du client
- Le
ReauthTokenGuardvérifie la correspondancesubentre le token principal et le reauth token - Rate limiting appliqué via
MfaRateLimitGuardsur tous les endpoints
Observation : Le guard MfaRateLimitGuard a un fail-open en cas d'indisponibilité Redis (ligne 91-93). Voir VUL-01.
A02:2021 — Cryptographic Failures¶
Statut : Acceptable avec observations
- Le reauth token utilise HS256 (symétrique) via
JwtModule— acceptable pour un token interne court-lived (5 min) - La vérification SRP utilise SHA3-256 pour le hashing, cohérent avec le protocole SRP
- L'implémentation
constantTimeEqualest custom (voir VUL-03 ci-dessous) - Les secrets TOTP sont gérés par Keycloak, pas par l'application
Observation : Le reauth token utilise la même clé JWT (security.jwt.secret) que les tokens principaux. Cela n'est pas critique car le champ purpose: 'reauth' est vérifié, mais une clé dédiée offrirait une meilleure séparation. Voir OBS-04.
A03:2021 — Injection¶
Statut : Bien couvert
- Les DTOs d'entrée utilisent
class-validatoravec des contraintes strictes : TotpVerifyRequestDto.code:@IsString(),@Length(6, 6),@Matches(/^[0-9]+$/)ReauthRequestDto.password:@IsString(),@MinLength(1)ValidationPipeglobal avecwhitelist: trueetforbidNonWhitelisted: truedansmain.ts- Les requêtes vers Keycloak utilisent
JSON.stringify(body), pas de concaténation de chaînes dans les URLs (lekeycloakUserIdprovient du JWT, pas d'un input utilisateur direct) - Pas de requêtes SQL directes, utilisation de TypeORM avec paramètres
Pas de vulnérabilité d'injection identifiée.
A07:2021 — Identification and Authentication Failures¶
Statut : Bien couvert
- L'authentification JWT est obligatoire sur tous les endpoints (class-level guard)
- La réauthentification par mot de passe est requise pour les opérations destructives
- Le reauth token a une durée de vie courte (5 min, configurable)
- Le token TOTP session a un TTL de 5 min dans Redis
- Rate limiting prévient le brute-force sur les codes TOTP
- Le
ReauthTokenGuardvérifie la signature JWT, le purpose, le sub, et l'expiration - Les erreurs d'authentification ne révèlent pas si l'utilisateur existe (
Re-authentication failedvsUser not founddans les logs seulement, pas dans la réponse HTTP)
Observation : Le ReauthService distingue USER_NOT_FOUND et INVALID_PASSWORD dans les logs (lignes 84 et 93), mais renvoie le même MfaReauthFailedError au client. C'est correct pour la réponse HTTP, mais les logs pourraient être utilisés pour de l'énumération d'utilisateurs par un attaquant avec accès aux logs.
Vulnérabilités identifiées¶
[MAJEURE] VUL-01 — Rate limiter fail-open en cas d'indisponibilité Redis¶
-
Description : Le
MfaRateLimitGuardretournetrue(autorise la requête) lorsque Redis n'est pas disponible (mfa-rate-limit.guard.ts:91-93). Un attaquant qui pourrait provoquer l'indisponibilité de Redis (ou un incident réseau) pourrait contourner le rate limiting et tenter un brute-force sur le code TOTP ou le mot de passe de réauthentification. -
Impact : Brute-force possible sur
/user/mfa/totp/verify(code TOTP 6 digits = 1M combinaisons) et/auth/reauth(mots de passe) en cas d'indisponibilité Redis. -
Fichier concerné :
src/modules/auth/guards/mfa-rate-limit.guard.ts:89-93 -
Remédiation proposée :
- Implémenter un rate limiter in-memory en fallback (compteur local avec Map + TTL)
- OU basculer vers un fail-closed qui rejette les requêtes si Redis est indisponible, avec monitoring/alerting sur cet état
- Ajouter un compteur de tentatives TOTP côté Keycloak comme defense-in-depth
- Documenter la décision architecturale si le fail-open est intentionnel (disponibilité vs sécurité)
[MAJEURE] VUL-02 — @Exclude() / @Expose() non fonctionnels sans ClassSerializerInterceptor¶
- Description : Les DTOs
TotpInitResponseDto,TotpVerifyResponseDtoetRecoveryRegenerateResponseDtoutilisent les décorateurs@Exclude()et@Expose()declass-transformerpour empêcher la sérialisation accidentelle des secrets. Cependant, aucunClassSerializerInterceptorn'est enregistré (ni globalement dansmain.ts, ni au niveau des controllers). Les décorateurs@Exclude()/@Expose()nécessitent cet intercepteur pour être fonctionnellement actifs.
En l'état actuel, si un middleware ou intercepteur tiers appelle JSON.stringify() ou plainToInstance() sur ces DTOs, les champs ne seront pas filtrés. Les annotations sont purement documentaires.
-
Impact : La protection contre la sérialisation accidentelle des secrets (INV-238-09 couche DTO) n'est pas active. Tout intercepteur de logging, middleware de monitoring, ou sérialisation manuelle inclura les secrets dans le résultat.
-
Fichier concerné :
src/modules/auth/mfa/dto/totp-init.dto.tssrc/modules/auth/mfa/dto/totp-verify.dto.tssrc/modules/auth/mfa/dto/recovery-regenerate.dto.ts-
src/main.ts(absence deClassSerializerInterceptor) -
Remédiation proposée :
- Ajouter
ClassSerializerInterceptorglobalement dansmain.ts: - OU l'ajouter au niveau du
MfaManagementController: - Ajouter un test unitaire vérifiant que la sérialisation d'un
TotpInitResponseDtosans@Expose()exclut bien les champs sensibles
[MINEURE] VUL-03 — constantTimeEqual custom au lieu de crypto.timingSafeEqual¶
- Description : Le
ReauthServiceimplémente une fonctionconstantTimeEqualcustom (ligne 214-225) pour la comparaison des verifiers SRP. Bien que l'implémentation semble correcte (XOR byte-by-byte), elle présente un early-return sur la comparaison de longueur (ligne 215 :if (a.length !== b.length) return false), ce qui constitue une fuite d'information potentielle sur la longueur du verifier.
De plus, Node.js fournit crypto.timingSafeEqual() qui est implémenté en C et audité, donc plus fiable qu'une implémentation userland en JavaScript.
-
Impact : Faible — Un attaquant devrait pouvoir mesurer des variations de timing de l'ordre de la nanoseconde sur le réseau, ce qui est extrêmement difficile en pratique. Le early-return sur la longueur est mitigé par le fait que les verifiers SRP ont toujours une longueur déterministe pour un même paramètre N.
-
Fichier concerné :
src/modules/auth/services/reauth.service.ts:214-225 -
Remédiation proposée :
[MINEURE] OBS-01 — ReauthRequestDto sans @Exclude() sur le champ password¶
-
Description : Le DTO
ReauthRequestDtone dispose pas d'un décorateur@Exclude()sur le champpassword. Bien que ce soit un DTO d'entrée (request body), si un intercepteur de logging ou d'audit serialise automatiquement le body des requêtes, le mot de passe sera inclus en clair. -
Impact : Faible — Dépend de la présence d'intercepteurs de logging du body. Le
AccessAuditInterceptorprésent dans le codebase pourrait potentiellement loguer le body. -
Fichier concerné :
src/modules/auth/dto/reauth.dto.ts -
Remédiation proposée : Ajouter
@Exclude()au niveau de la classe et@Expose()seulement sur les champs non-sensibles, ou ne pas exposer le password via le toString/toJSON. Alternativement, vérifier que l'AccessAuditInterceptorexclut explicitement les bodies des endpoints d'authentification.
[MINEURE] OBS-02 — Pas de MfaRateLimitGuard sur POST /auth/reauth¶
- Description : L'endpoint
POST /auth/reauthn'utilise pas leMfaRateLimitGuard. Il est protégé parOidcJwtAuthGuarduniquement. Un attaquant avec un JWT valide pourrait tenter de brute-forcer le mot de passe via la réauthentification sans limitation de débit spécifique.
Il est possible qu'un RateLimitGuard générique couvre ce cas via le module AuthModule, mais il n'est pas explicitement appliqué sur le endpoint POST /auth/reauth dans reauth.controller.ts.
-
Impact : Moyen — Un attaquant authentifié pourrait tenter un nombre illimité de mots de passe via
/auth/reauth. -
Fichier concerné :
src/modules/auth/controllers/reauth.controller.ts -
Remédiation proposée : Appliquer
@UseGuards(MfaRateLimitGuard)ou un guard de rate limiting dédié sur l'endpointPOST /auth/reauth. Considérer un rate limit plus restrictif (ex: 5 tentatives / 5 min) car cet endpoint accepte un mot de passe.
[MINEURE] OBS-03 — Messages de log distinguant USER_NOT_FOUND et INVALID_PASSWORD¶
-
Description : Dans
reauth.service.ts, les logs distinguentReauth failed: User not found(ligne 84) etReauth failed: Invalid password(ligne 93). Bien que la réponse HTTP soit identique (MfaReauthFailedError), un attaquant avec accès aux logs (via compromission du système de logging) pourrait utiliser ces messages pour déterminer si un userId existe. -
Impact : Très faible — Nécessite un accès aux logs de l'application, ce qui implique déjà une compromission partielle.
-
Fichier concerné :
src/modules/auth/services/reauth.service.ts:84,93 -
Remédiation proposée : Unifier les messages de log :
Reauth failed for userId=${userId}sans distinguer la raison. La raison peut être émise via l'event de sécurité (déjà fait viaemitReauthFailed) pour le monitoring, mais le logger textuel ne devrait pas distinguer.
[MINEURE] OBS-04 — Clé JWT partagée entre tokens principaux et reauth tokens¶
-
Description : Le
MfaModuleconfigureJwtModuleavec la même clésecurity.jwt.secretque celle utilisée pour les tokens principaux dansAuthModule. Bien que le champpurpose: 'reauth'soit vérifié dans leReauthTokenGuard, le partage de clé signifie qu'un token principal pourrait théoriquement être présenté comme reauth token si le guard ne vérifie pas le purpose (ce qu'il fait correctement). -
Impact : Très faible — Le
ReauthTokenGuardvérifiepayload.purpose === 'reauth'(ligne 74), donc un token principal (sans champ purpose) serait rejeté. La séparation de clés est une bonne pratique de defense-in-depth mais non critique ici. -
Fichier concerné :
src/modules/auth/mfa/mfa.module.ts:61-71 -
Remédiation proposée : Considérer l'utilisation d'une clé JWT dédiée (
security.jwt.reauthSecret) pour les reauth tokens. Cela offre une isolation cryptographique : la compromission de la clé principale ne compromettrait pas les reauth tokens et vice-versa.
Synthèse des fichiers audités¶
| Fichier | Lignes | Statut |
|---|---|---|
mfa/services/keycloak-admin.service.ts | 838 | OK |
mfa/services/mfa-management.service.ts | 416 | OK |
mfa/controllers/mfa-management.controller.ts | 238 | OK |
services/reauth.service.ts | 251 | VUL-03, OBS-03 |
controllers/reauth.controller.ts | 78 | OBS-02 |
mfa/guards/reauth-token.guard.ts | 89 | OK |
guards/mfa-rate-limit.guard.ts | 147 | VUL-01 |
mfa/errors/mfa-management.errors.ts | 155 | OK |
mfa/filters/mfa-exception.filter.ts | 63 | OK |
mfa/dto/mfa-status.dto.ts | 33 | OK |
mfa/dto/totp-init.dto.ts | 50 | VUL-02 |
mfa/dto/totp-verify.dto.ts | 67 | VUL-02 |
mfa/dto/mfa-disable.dto.ts | 18 | OK |
mfa/dto/recovery-regenerate.dto.ts | 39 | VUL-02 |
dto/reauth.dto.ts | 57 | OBS-01 |
mfa/config/keycloak-admin.config.ts | 209 | OK |
mfa/mfa.module.ts | 137 | OBS-04 |
decorators/current-user.decorator.ts | 44 | OK |
main.ts | 50+ | VUL-02 (absence ClassSerializerInterceptor) |
Recommandations¶
Priorité haute¶
-
[VUL-01] Implémenter un fallback in-memory pour le rate limiter — Le fail-open actuel est un choix de disponibilité qui compromet la sécurité. Ajouter un rate limiter local en mémoire comme fallback quand Redis est indisponible. Un simple
Map<string, { count: number, resetAt: number }>suffit pour un fallback basique. -
[VUL-02] Activer ClassSerializerInterceptor — Soit globalement dans
main.ts, soit au niveau du controller MFA. Valider par un test que les champs sensibles sont bien exclus lors de la sérialisation. C'est une protection defense-in-depth essentielle pour INV-238-09.
Priorité moyenne¶
-
[OBS-02] Ajouter un rate limiting sur POST /auth/reauth — Cet endpoint accepte un mot de passe et doit être protégé contre le brute-force au même titre que les endpoints MFA.
-
[VUL-03] Utiliser
crypto.timingSafeEqualau lieu de l'implémentation custom. C'est une correction simple qui élimine un risque théorique de timing attack.
Priorité basse¶
-
[OBS-01] Protéger le champ password du ReauthRequestDto contre la sérialisation accidentelle, ou vérifier que les intercepteurs d'audit excluent le body de ce endpoint.
-
[OBS-03] Unifier les messages de log de réauthentification pour ne pas distinguer USER_NOT_FOUND et INVALID_PASSWORD dans les logs textuels.
-
[OBS-04] Évaluer la séparation des clés JWT pour les reauth tokens. C'est du defense-in-depth non critique mais conforme aux bonnes pratiques.
Threat Model — Vecteurs d'attaque évalués¶
| Vecteur | Mitigé ? | Mécanisme |
|---|---|---|
| TOTP brute-force (6 digits) | Partiellement | Rate limit (fail-open si Redis down) |
| Accès croisé MFA | Oui | userId du JWT, pas de paramètre client |
| Fuite de secrets dans les logs | Oui | Logs génériques, pas de valeurs sensibles |
| Stockage local de secrets | Oui | Pattern pass-through, Redis = sessionId only |
| Replay de reauth token | Oui | TTL 5min, purpose check, sub match |
| Vol de reauth token | Partiellement | Token court (5min), mais pas de one-time-use |
| Timing attack sur SRP verifier | Partiellement | Custom constant-time (recommandation: crypto natif) |
| Enumeration d'utilisateurs via reauth | Oui (HTTP) | Même erreur HTTP, logs distinctifs acceptables |
| SSRF via Keycloak Admin URL | Oui | URL configurée via env var, pas d'input utilisateur |
| Circuit breaker DoS | Oui | Auto-recovery après 30s, half-open progressif |
Revue effectuée par agent-adversarial le 2026-02-06. Prochaine revue recommandée après correction des VUL-01 et VUL-02.