fix(auth): leurre anti-enumeration sur la demande de reset (parite timing/ecritures)
All checks were successful
CI / php-lint (push) Successful in 22s
CI / php-lint (pull_request) Successful in 22s
CI / static-tests (pull_request) Successful in 36s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Successful in 6s
CI / secret-scan (push) Successful in 11s
CI / static-tests (push) Successful in 35s
CI / secret-scan (pull_request) Successful in 8s
All checks were successful
CI / php-lint (push) Successful in 22s
CI / php-lint (pull_request) Successful in 22s
CI / static-tests (pull_request) Successful in 36s
CI / auto-merge (push) Has been skipped
CI / auto-merge (pull_request) Successful in 6s
CI / secret-scan (push) Successful in 11s
CI / static-tests (push) Successful in 35s
CI / secret-scan (pull_request) Successful in 8s
PasswordResetService::requestReset repondait instantanement et sans ecriture sur un email inconnu, alors que le chemin email-connu genere un token (random_bytes + SHA-256), fait un UPDATE et envoie un mail : oracle de timing + de profil d'ecritures revelant l'existence d'un compte. Le commit P2 #11 annoncait pourtant une parite anti-enumeration PR-wide (vraie sur le login, pas sur le reset). payEnumerationDecoy() reproduit le cout CPU et UNE ecriture du chemin connu, mais sur id = 0 (aucune ligne, rien persiste) ; aucun mail. Meme pattern que le leurre du login (UPDATE no-op sur id = 0). Tests unknown-email (service + controleur) mis a jour : 1 ecriture leurre sur id=0, pas de mail. Suite 188 verte, PHPStan L6.
This commit is contained in:
parent
ad5203d3fc
commit
35ead030b1
3 changed files with 38 additions and 3 deletions
|
|
@ -38,6 +38,13 @@ final class PasswordResetService
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($user === null) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +67,26 @@ final class PasswordResetService
|
||||||
$this->mailer->sendPasswordReset($email, $resetUrl);
|
$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 +
|
* 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
|
* expiration future (la recherche par egalite sur un token 256 bits EST la
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,10 @@ final class PasswordResetControllerTest extends TestCase
|
||||||
self::assertSame(200, $response->status());
|
self::assertSame(200, $response->status());
|
||||||
self::assertStringContainsString('Si un compte', $response->body());
|
self::assertStringContainsString('Si un compte', $response->body());
|
||||||
self::assertSame([], $mailer->sent);
|
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
|
public function testSubmitConfirmPasswordMismatchRendersError(): void
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,18 @@ final class PasswordResetServiceTest extends TestCase
|
||||||
return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer);
|
return new PasswordResetService($this->db, new Config(), $this->hasher, $this->mailer);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRequestUnknownEmailWritesNothingAndSendsNoMail(): void
|
public function testRequestUnknownEmailPaysDecoyWriteAndSendsNoMail(): void
|
||||||
{
|
{
|
||||||
$this->db->emailLookupRow = null;
|
$this->db->emailLookupRow = null;
|
||||||
|
|
||||||
$this->service()->requestReset('ghost@wakdo.local', 'https://admin.wakdo.test', self::NOW);
|
$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);
|
self::assertSame([], $this->mailer->sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue