From 6557dd9c6c203c8d989f57da8e79a1c8a3681c07 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Tue, 16 Jun 2026 14:20:59 +0200 Subject: [PATCH] fix(auth): leurre anti-enumeration sur la demande de reset (#26) --- src/app/Auth/PasswordResetService.php | 27 +++++++++++++++++++ .../Unit/Auth/PasswordResetControllerTest.php | 5 +++- tests/Unit/Auth/PasswordResetServiceTest.php | 9 +++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/app/Auth/PasswordResetService.php b/src/app/Auth/PasswordResetService.php index 46c81e7..c0a2869 100644 --- a/src/app/Auth/PasswordResetService.php +++ b/src/app/Auth/PasswordResetService.php @@ -38,6 +38,13 @@ final class PasswordResetService ); if ($user === null) { + // Anti-enumeration (RG-2) : egaliser le profil du chemin email-inconnu + // avec celui du chemin email-connu (meme cout de generation de token + + // UNE ecriture), sans rien persister d'exploitable. Sans ce leurre, une + // reponse instantanee et zero ecriture trahissent qu'aucun compte ne + // correspond a l'email. + $this->payEnumerationDecoy($now); + return; } @@ -60,6 +67,26 @@ final class PasswordResetService $this->mailer->sendPasswordReset($email, $resetUrl); } + /** + * Leurre anti-enumeration du chemin email-inconnu : reproduit le cout CPU + * (generation d'un token CSPRNG + SHA-256) et UNE ecriture du chemin + * email-connu, mais cible id = 0 (aucune ligne affectee, rien persiste). Le + * temps de reponse et le nombre d'ecritures ne revelent plus l'existence + * d'un compte (parite avec requestReset cote email connu). + */ + private function payEnumerationDecoy(int $now): void + { + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $ttl = $this->config->int('PASSWORD_RESET_TTL', 3600); + $expiresAt = date('Y-m-d H:i:s', $now + $ttl); + + $this->db->execute( + 'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = 0', + ['hash' => $tokenHash, 'exp' => $expiresAt], + ); + } + /** * Phase confirmation (RG-3/RG-4). Hash du token soumis, recherche par hash + * expiration future (la recherche par egalite sur un token 256 bits EST la diff --git a/tests/Unit/Auth/PasswordResetControllerTest.php b/tests/Unit/Auth/PasswordResetControllerTest.php index 2f8d25c..2e1e107 100644 --- a/tests/Unit/Auth/PasswordResetControllerTest.php +++ b/tests/Unit/Auth/PasswordResetControllerTest.php @@ -134,7 +134,10 @@ final class PasswordResetControllerTest extends TestCase self::assertSame(200, $response->status()); self::assertStringContainsString('Si un compte', $response->body()); self::assertSame([], $mailer->sent); - self::assertSame([], $db->writes); + // Anti-enumeration : un leurre (UPDATE no-op sur id = 0) aligne le profil + // d'ecritures sur le chemin email-connu ; rien n'est persiste. + self::assertCount(1, $db->writes); + self::assertStringContainsString('WHERE id = 0', $db->writes[0]['sql']); } public function testSubmitConfirmPasswordMismatchRendersError(): void diff --git a/tests/Unit/Auth/PasswordResetServiceTest.php b/tests/Unit/Auth/PasswordResetServiceTest.php index ada4e15..8b86549 100644 --- a/tests/Unit/Auth/PasswordResetServiceTest.php +++ b/tests/Unit/Auth/PasswordResetServiceTest.php @@ -57,13 +57,18 @@ final class PasswordResetServiceTest extends TestCase return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer); } - public function testRequestUnknownEmailWritesNothingAndSendsNoMail(): void + public function testRequestUnknownEmailPaysDecoyWriteAndSendsNoMail(): void { $this->db->emailLookupRow = null; $this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW); - self::assertSame([], $this->db->writes); + // Anti-enumeration : meme profil que le chemin email-connu (UNE ecriture + // de la meme forme), mais ciblant id = 0 -> rien persiste ; et aucun mail. + self::assertCount(1, $this->db->writes); + $decoy = $this->db->writes[0]; + self::assertStringContainsString('password_reset_token_hash = :hash', $decoy['sql']); + self::assertStringContainsString('WHERE id = 0', $decoy['sql']); self::assertSame([], $this->mailer->sent); }