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.
68 lines
2.1 KiB
TypeScript
68 lines
2.1 KiB
TypeScript
/** Simple sliding-window rate limiter keyed by arbitrary string (ip, ip+user). */
|
|
export class RateLimiter {
|
|
private hits = new Map<string, number[]>();
|
|
|
|
constructor(
|
|
private readonly max: number,
|
|
private readonly windowMs: number,
|
|
) {
|
|
const t = setInterval(() => this.prune(), windowMs);
|
|
t.unref();
|
|
}
|
|
|
|
/** Returns true if the action is allowed, false if the caller should back off. */
|
|
check(key: string): boolean {
|
|
const now = Date.now();
|
|
const arr = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
|
|
if (arr.length >= this.max) {
|
|
this.hits.set(key, arr);
|
|
return false;
|
|
}
|
|
arr.push(now);
|
|
this.hits.set(key, arr);
|
|
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);
|
|
}
|
|
|
|
retryAfterSeconds(key: string): number {
|
|
const arr = this.hits.get(key);
|
|
if (!arr || !arr.length) return 0;
|
|
const oldest = arr[0]!;
|
|
return Math.max(1, Math.ceil((this.windowMs - (Date.now() - oldest)) / 1000));
|
|
}
|
|
|
|
private prune(): void {
|
|
const now = Date.now();
|
|
for (const [k, arr] of this.hits) {
|
|
const kept = arr.filter((t) => now - t < this.windowMs);
|
|
if (kept.length) this.hits.set(k, kept);
|
|
else this.hits.delete(k);
|
|
}
|
|
}
|
|
}
|