Files
ihasmail/server/src/ratelimit.ts
T
jcoffey-dev 607afeb4ad 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.
2026-09-02 14:08:23 -07:00

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);
}
}
}