Answer a failed reset the same way whatever failed

The screen leaked account existence a second way, through the write, and
this one is older than last night's: it is the scaffolding. Laravel
answers a failed reset with passwords.user for an address it cannot find
and passwords.token for a real one whose token is dead, and the controller
surfaced __($status) straight through. Two sentences, one difference, and
the difference is whether the account is here.

passwords.throttled is the third and the sharpest. The broker throttles
per user, so an address nobody holds can never be throttled — being told
to wait is being told the account exists.

All of them collapse to one sentence now. Nothing is lost: the action is
the same in every case, and /forgot-password one step earlier already
refuses to say whether an address has an account. Keeping three messages
was only ever more precise about a thing we had decided not to say.

Found because the portal session went looking for the GET oracle I had
just fixed, found theirs, and also found a POST variant I had not thought
to check. I had it too.

The test asserts the two refusals are identical rather than naming the
sentence, so it survives the wording changing.
This commit is contained in:
ignacionelson
2026-09-09 08:04:54 -03:00
parent 7c4b582d25
commit 6339ae1514
19 changed files with 75 additions and 17 deletions
+6
View File
@@ -38,6 +38,12 @@ installation from starting.
**Fixed**
- **The password reset screen no longer says whether an email address has an account here.** Asking
to reset a password already answers "a link will be sent if the account exists" without confirming
either way; the screen the link leads to did confirm it, twice — once by showing a form for an
unknown address and an expired notice for a real one, and once by answering "we can't find a user
with that email address" instead of "this token is invalid". Both now give the same answer to
everybody, and every failure reads the same: ask for a new link.
- **An expired password reset link now says so before asking for a new password.** The page took a
password, took it a second time to confirm, and only then answered "this password reset token is
invalid" — a word nobody outside the code knows, at the end of the work rather than the start.
@@ -157,8 +157,21 @@ class NewPasswordController extends Controller
return to_route('login')->with('status', __($status));
}
// One sentence for every way this can fail, and deliberately not
// Laravel's own. The scaffolding answers `passwords.user` for an
// address it cannot find and `passwords.token` for a real one whose
// token is dead — two different sentences, which is the same
// account-enumeration oracle the screen above was fixed for,
// reachable through the write instead. `passwords.throttled` is the
// third and the sharpest: the broker throttles per *user*, so an
// address nobody holds can never be throttled, and being told to
// wait is being told the account is there.
//
// Nothing is lost by collapsing them. The action is the same in
// every case — ask for a new link — and /forgot-password already
// refuses to say whether an address has an account.
throw ValidationException::withMessages([
'email' => [__($status)],
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
]);
}
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "La teva adreça de correu prové del directori o del proveïdor d'identitat amb què inicies sessió, i no es pot canviar aquí.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Els enllaços de restabliment duren una hora. Demana'n un de nou i arribarà de seguida.",
"Send me a new link": "Envia'm un enllaç nou",
"This link has expired": "Aquest enllaç ha caducat"
"This link has expired": "Aquest enllaç ha caducat",
"This password reset link is no longer valid. Ask for a new one and try again.": "Aquest enllaç de restabliment ja no és vàlid. Demana'n un de nou i torna-ho a provar."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Vaše e-mailová adresa pochází z adresáře nebo poskytovatele identity, přes kterého se přihlašujete, a nelze ji zde změnit.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Odkazy pro obnovení platí jednu hodinu. Požádejte o nový a za chvíli dorazí.",
"Send me a new link": "Pošlete mi nový odkaz",
"This link has expired": "Platnost tohoto odkazu vypršela"
"This link has expired": "Platnost tohoto odkazu vypršela",
"This password reset link is no longer valid. Ask for a new one and try again.": "Tento odkaz pro obnovení už neplatí. Požádejte o nový a zkuste to znovu."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ihre E-Mail-Adresse stammt aus dem Verzeichnis oder Identitätsanbieter, mit dem Sie sich anmelden, und kann hier nicht geändert werden.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Zurücksetzungslinks gelten eine Stunde. Fordern Sie einen neuen an, er trifft gleich ein.",
"Send me a new link": "Neuen Link senden",
"This link has expired": "Dieser Link ist abgelaufen"
"This link has expired": "Dieser Link ist abgelaufen",
"This password reset link is no longer valid. Ask for a new one and try again.": "Dieser Link zum Zurücksetzen ist nicht mehr gültig. Fordern Sie einen neuen an und versuchen Sie es erneut."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Tu dirección de correo viene del directorio o proveedor de identidad con el que inicias sesión, y no se puede cambiar aquí.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Los enlaces de restablecimiento duran una hora. Pedí uno nuevo y llegará en un momento.",
"Send me a new link": "Enviame un enlace nuevo",
"This link has expired": "Este enlace ha caducado"
"This link has expired": "Este enlace ha caducado",
"This password reset link is no longer valid. Ask for a new one and try again.": "Este enlace de restablecimiento ya no es válido. Pedí uno nuevo y volvé a intentarlo."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Votre adresse e-mail provient de l'annuaire ou du fournisseur d'identité avec lequel vous vous connectez, et ne peut pas être modifiée ici.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Les liens de réinitialisation durent une heure. Demandez-en un nouveau, il arrivera dans un instant.",
"Send me a new link": "Envoyez-moi un nouveau lien",
"This link has expired": "Ce lien a expiré"
"This link has expired": "Ce lien a expiré",
"This password reset link is no longer valid. Ask for a new one and try again.": "Ce lien de réinitialisation n'est plus valide. Demandez-en un nouveau et réessayez."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Alamat email Anda berasal dari direktori atau penyedia identitas tempat Anda masuk, dan tidak dapat diubah di sini.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Tautan atur ulang berlaku satu jam. Minta yang baru dan akan tiba sebentar lagi.",
"Send me a new link": "Kirimi saya tautan baru",
"This link has expired": "Tautan ini sudah kedaluwarsa"
"This link has expired": "Tautan ini sudah kedaluwarsa",
"This password reset link is no longer valid. Ask for a new one and try again.": "Tautan atur ulang ini sudah tidak berlaku. Minta yang baru dan coba lagi."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Il tuo indirizzo email proviene dalla directory o dal provider di identità con cui accedi e non può essere modificato qui.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "I link di reimpostazione durano un'ora. Chiedine uno nuovo e arriverà tra un momento.",
"Send me a new link": "Inviami un nuovo link",
"This link has expired": "Questo link è scaduto"
"This link has expired": "Questo link è scaduto",
"This password reset link is no longer valid. Ask for a new one and try again.": "Questo link di reimpostazione non è più valido. Chiedine uno nuovo e riprova."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "メールアドレスはサインインに使用しているディレクトリまたは ID プロバイダーのもので、ここでは変更できません。",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "再設定リンクの有効期間は 1 時間です。新しいリンクを申し込めば、すぐに届きます。",
"Send me a new link": "新しいリンクを送る",
"This link has expired": "このリンクは有効期限が切れています"
"This link has expired": "このリンクは有効期限が切れています",
"This password reset link is no longer valid. Ask for a new one and try again.": "この再設定リンクは無効になりました。新しいリンクを申し込んでもう一度お試しください。"
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Je e-mailadres komt van de directory of identiteitsprovider waarmee je je aanmeldt en kan hier niet worden gewijzigd.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Herstellinks zijn een uur geldig. Vraag een nieuwe aan, die komt zo binnen.",
"Send me a new link": "Stuur me een nieuwe link",
"This link has expired": "Deze link is verlopen"
"This link has expired": "Deze link is verlopen",
"This password reset link is no longer valid. Ask for a new one and try again.": "Deze herstellink is niet meer geldig. Vraag een nieuwe aan en probeer het opnieuw."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Twój adres e-mail pochodzi z katalogu lub dostawcy tożsamości, przez którego się logujesz, i nie można go tu zmienić.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Linki do resetowania są ważne godzinę. Poproś o nowy, dotrze za chwilę.",
"Send me a new link": "Wyślij mi nowy link",
"This link has expired": "Ten link wygasł"
"This link has expired": "Ten link wygasł",
"This password reset link is no longer valid. Ask for a new one and try again.": "Ten link do resetowania nie jest już ważny. Poproś o nowy i spróbuj ponownie."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Seu endereço de e-mail vem do diretório ou provedor de identidade com que você entra, e não pode ser alterado aqui.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Os links de redefinição duram uma hora. Peça um novo e ele chega em instantes.",
"Send me a new link": "Envie-me um novo link",
"This link has expired": "Este link expirou"
"This link has expired": "Este link expirou",
"This password reset link is no longer valid. Ask for a new one and try again.": "Este link de redefinição não é mais válido. Peça um novo e tente de novo."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ваш адрес электронной почты берётся из каталога или поставщика удостоверений, через который вы входите, и здесь его изменить нельзя.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Ссылки для сброса действуют один час. Запросите новую — она придёт через мгновение.",
"Send me a new link": "Прислать новую ссылку",
"This link has expired": "Срок действия ссылки истёк"
"This link has expired": "Срок действия ссылки истёк",
"This password reset link is no longer valid. Ask for a new one and try again.": "Эта ссылка для сброса больше не действует. Запросите новую и попробуйте снова."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Anwani yako ya barua pepe inatoka kwenye saraka au mtoa utambulisho unaotumia kuingia, na haiwezi kubadilishwa hapa.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Viungo vya kuweka upya hudumu saa moja. Omba kipya na kitafika punde.",
"Send me a new link": "Nitumie kiungo kipya",
"This link has expired": "Kiungo hiki kimeisha muda"
"This link has expired": "Kiungo hiki kimeisha muda",
"This password reset link is no longer valid. Ask for a new one and try again.": "Kiungo hiki cha kuweka upya hakifanyi kazi tena. Omba kipya kisha ujaribu tena."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "E-posta adresiniz, oturum açtığınız dizinden veya kimlik sağlayıcısından gelir ve burada değiştirilemez.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Sıfırlama bağlantıları bir saat geçerlidir. Yenisini isteyin, birazdan ulaşır.",
"Send me a new link": "Bana yeni bir bağlantı gönder",
"This link has expired": "Bu bağlantının süresi doldu"
"This link has expired": "Bu bağlantının süresi doldu",
"This password reset link is no longer valid. Ask for a new one and try again.": "Bu sıfırlama bağlantısı artık geçerli değil. Yenisini isteyip tekrar deneyin."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Địa chỉ email của bạn đến từ thư mục hoặc nhà cung cấp danh tính mà bạn dùng để đăng nhập, và không thể thay đổi tại đây.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Liên kết đặt lại có hiệu lực một giờ. Hãy yêu cầu liên kết mới, nó sẽ đến ngay.",
"Send me a new link": "Gửi cho tôi liên kết mới",
"This link has expired": "Liên kết này đã hết hạn"
"This link has expired": "Liên kết này đã hết hạn",
"This password reset link is no longer valid. Ask for a new one and try again.": "Liên kết đặt lại này không còn hiệu lực. Hãy yêu cầu liên kết mới và thử lại."
}
+2 -1
View File
@@ -2027,5 +2027,6 @@
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "你的邮箱地址来自你用于登录的目录或身份提供方,无法在此处修改。",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "重置链接有效期为一小时。申请一个新的,稍后即可收到。",
"Send me a new link": "给我发送新链接",
"This link has expired": "此链接已过期"
"This link has expired": "此链接已过期",
"This password reset link is no longer valid. Ask for a new one and try again.": "此重置链接已失效。请申请一个新的链接后重试。"
}
@@ -78,6 +78,29 @@ test('an address nobody has gets the same answer as one that exists', function (
expect($expiredFor('known@example.com'))->toBe($expiredFor('nobody@example.com'));
});
test('the write refuses both the same way', function () {
// The GET is not the only way to ask. Laravel answers a failed reset
// with passwords.user for an address it cannot find and passwords.token
// for a real one whose token is dead — two different sentences, which
// is the same oracle through the POST. This one predates the screen
// above; it is the scaffolding, shipped in every release.
User::factory()->create(['email' => 'known@example.com']);
$refusalFor = function (string $email): string {
return test()->from('/reset-password/not-a-real-token')
->post('/reset-password', [
'token' => 'not-a-real-token',
'email' => $email,
'password' => 'a-brand-new-password',
'password_confirmation' => 'a-brand-new-password',
])
->assertSessionHasErrors('email')
->getSession()->get('errors')->first('email');
};
expect($refusalFor('known@example.com'))->toBe($refusalFor('nobody@example.com'));
});
test('a missing address reads as expired rather than as a form', function () {
// Same rule seen from the other side. The form needs an address to
// post, so drawing it here would ask for a password it cannot use.