fix(auth): leurre anti-enumeration sur la demande de reset (#26)
Some checks failed
CI / secret-scan (push) Has been cancelled
CI / static-tests (push) Has been cancelled
CI / php-lint (push) Has been cancelled
CI / auto-merge (push) Has been cancelled

This commit is contained in:
Corentin JOGUET 2026-06-16 14:20:59 +02:00
parent 9ddb4ccb27
commit 6557dd9c6c
3 changed files with 38 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -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);
}