fix(core): Throttle lost-password reset form when link is disabled

When password reset links were disabled, the reset form did not rate-limit
failed token attempts, leaving that endpoint open to unthrottled token
guessing. Failed attempts on the form are now always throttled, consistent
with the other reset steps.

Signed-off-by: nfebe <fenn25.fn@gmail.com>
This commit is contained in:
nfebe 2026-06-26 19:49:16 +01:00 committed by Daniel Kesselberg
parent 39d2dc253e
commit f9e274dfe3
No known key found for this signature in database
GPG key ID: 4A81C29F63464E8F
2 changed files with 41 additions and 20 deletions

View file

@ -90,24 +90,19 @@ class LostController extends Controller {
try {
$this->checkPasswordResetToken($token, $userId);
} catch (Exception $e) {
if ($this->config->getSystemValue('lost_password_link', '') !== 'disabled'
|| ($e instanceof InvalidTokenException
&& !in_array($e->getCode(), [InvalidTokenException::TOKEN_NOT_FOUND, InvalidTokenException::USER_UNKNOWN]))
) {
$response = new TemplateResponse(
'core', 'error', [
'errors' => [['error' => $e->getMessage()]]
],
TemplateResponse::RENDER_AS_GUEST
);
$response->throttle();
return $response;
if ($this->config->getSystemValue('lost_password_link', '') === 'disabled') {
$message = $this->l10n->t('Password reset is disabled');
} else {
$message = $e->getMessage();
}
return new TemplateResponse('core', 'error', [
'errors' => [['error' => $this->l10n->t('Password reset is disabled')]]
],
$response = new TemplateResponse(
'core', 'error', [
'errors' => [['error' => $message]]
],
TemplateResponse::RENDER_AS_GUEST
);
$response->throttle();
return $response;
}
$this->initialState->provideInitialState('resetPasswordUser', $userId);
$this->initialState->provideInitialState('resetPasswordTarget',

View file

@ -53,6 +53,7 @@ class LostControllerTest extends TestCase {
private $defaults;
/** @var IConfig | MockObject */
private $config;
private string $lostPasswordLink = '';
/** @var IMailer | MockObject */
private $mailer;
/** @var IManager|MockObject */
@ -93,11 +94,13 @@ class LostControllerTest extends TestCase {
$this->config = $this->createMock(IConfig::class);
$this->config->expects($this->any())
->method('getSystemValue')
->willReturnMap([
['secret', null, 'SECRET'],
['secret', '', 'SECRET'],
['lost_password_link', '', ''],
]);
->willReturnCallback(function (string $key, $default = '') {
return match ($key) {
'secret' => 'SECRET',
'lost_password_link' => $this->lostPasswordLink,
default => $default,
};
});
$this->l10n = $this->createMock(IL10N::class);
$this->l10n
->expects($this->any())
@ -162,6 +165,29 @@ class LostControllerTest extends TestCase {
$this->assertEquals($expectedResponse, $response);
}
public function testResetFormTokenErrorWithDisabledLink(): void {
$this->lostPasswordLink = 'disabled';
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->verificationToken->expects($this->once())
->method('check')
->with('12345:MySecretToken', $this->existingUser, 'lostpassword', 'test@example.com')
->willThrowException(new InvalidTokenException(InvalidTokenException::TOKEN_NOT_FOUND));
$response = $this->lostController->resetform('12345:MySecretToken', 'ValidTokenUser');
$expectedResponse = new TemplateResponse('core',
'error',
[
'errors' => [
['error' => 'Password reset is disabled'],
]
],
'guest');
$expectedResponse->throttle();
$this->assertEquals($expectedResponse, $response);
}
public function testResetFormValidToken(): void {
$this->userManager->method('get')
->with('ValidTokenUser')