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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user