Do not spend login attempts on an outage nobody caused

ihasmail runs in its own container, usually on its own host, so Stalwart being
briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right:
a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none
of which reads as a rejected password.

What it got wrong was the counting. RateLimiter.check() consumes an attempt
when it is called, and it is called before the upstream is contacted; reset()
only runs on success. So every try against an unreachable server burned a
credential attempt, and after ten of them the person was locked out for the
rest of the fifteen-minute window -- including after the server came back. A
thirty-second blip became a quarter-hour lockout, and the second failure was
entirely ihasmail's own doing.

A 401 is a judgement about the password and stays counted. A 502 or 504 is the
upstream failing to answer, says nothing about the credentials, and is now
refunded -- one attempt back, not the key cleared, so a run of real failures
with an outage in the middle still adds up. The old-server refusal refunds too:
those credentials were accepted.

Both guessing keys are refunded, not just the username one. Refunding only
that would not have fixed it -- ten retries still spend the per-address budget,
and behind one office NAT that budget belongs to the whole building, so a
company-wide outage would lock out the company.

Which needs a backstop, because "not counted" must not mean "unlimited": each
attempt still costs an outbound connection that may sit there until
UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to
abuse. So there is a second ceiling per address, twenty times looser and never
refunded. A person retrying will not come near it; something hammering will.

Both messages now say the quiet part -- "This is not a problem with your
password" -- for somebody already worried they have forgotten it.

Closes #239.
This commit is contained in:
2026-09-02 14:08:23 -07:00
parent f6f6ce0b5e
commit 607afeb4ad
4 changed files with 173 additions and 2 deletions
+22
View File
@@ -23,6 +23,28 @@ export class RateLimiter {
return true;
}
/**
* Give back the attempt `check` just took.
*
* For an outcome that says nothing about whether the credentials were right.
* ihasmail runs in its own container, usually on its own host, so an upstream
* that never answered is an ordinary Tuesday rather than an attack -- and the
* limiter exists to slow down password guessing, which a server that refused
* the connection has not told us anything about. Without this, retrying
* through a thirty-second outage spends the window and locks somebody out
* until well after the cause has gone (#239).
*
* Refunds one attempt rather than clearing the key, so a run of real failures
* with an outage in the middle still adds up.
*/
refund(key: string): void {
const arr = this.hits.get(key);
if (!arr?.length) return;
arr.pop();
if (arr.length) this.hits.set(key, arr);
else this.hits.delete(key);
}
reset(key: string): void {
this.hits.delete(key);
}