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.
I built the oracle in the commit whose docblock describes preventing it.
The comment said a page answering "expired" for a real address and
something else for an unknown one would tell anybody who typed a guess
whether an account is here — and then the method returned false for an
unknown address and true for a known one. Two branches, two answers, and
the difference was the account.
/forgot-password deliberately says "a link will be sent if the account
exists". This undid that on the next screen along.
Both branches answer the same now: anything that will not validate reads
as expired, whether the address is known, unknown or absent. The message
stays right in every case somebody real will meet — a mistyped address
gets "ask for a new link", which is what they should do anyway — and the
page reveals nothing.
The test is written as "these two are the same answer" rather than "both
are false", so it keeps holding if somebody later changes which answer it
is. The two tests that encoded the oracle asserted `expired` was false for
an unknown address; they were pinning the bug.
Found by asking my own question of my own code. The portal session had
checked whether their collation folded addresses, which sent me back to
the reset screen to see what it does with an address it cannot place.
The page rendered the form without looking at the token, so somebody
opening a link an hour late typed a password, typed it again to confirm,
and was then told "this password reset token is invalid" — a word nobody
outside the code knows, at the end rather than the start. Links last an
hour and people open them late. That is ordinary, not an error to be
scolded for.
store() still validates and is still the rule; there is a test that a
spent token is refused there whatever the page drew. This is only the
screen being honest a minute earlier.
An address that is missing, or belongs to nobody, is drawn as the form was
before. Partly because an unanswerable question is not an expired link,
but mostly because a page that said "expired" for a real address and
something else for an unknown one would answer whether an account exists
here to anybody typing guesses — the exact property /forgot-password
protects by saying "a link will be sent if the account exists". Two tests
pin that.
Worth having now rather than later: the advisories publishing with this
release will send more people than usual through this screen, in a hurry
and some of them frightened.
Found by the portal session's user, who opened a real link an hour and
forty minutes after it was sent.