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:
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
|
||||
/**
|
||||
* The limiter's job is to slow down password guessing. #239 is about the
|
||||
* attempts it takes for outcomes that were never a guess: ihasmail runs apart
|
||||
* from Stalwart, so an upstream that refuses a connection is ordinary, and
|
||||
* retrying through one used to spend the window and lock somebody out until
|
||||
* after the cause had gone.
|
||||
*/
|
||||
|
||||
test("check allows up to the limit and then refuses", () => {
|
||||
const rl = new RateLimiter(3, 60_000);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), false);
|
||||
});
|
||||
|
||||
test("refund gives back exactly one attempt", () => {
|
||||
const rl = new RateLimiter(2, 60_000);
|
||||
rl.check("k");
|
||||
rl.check("k");
|
||||
assert.equal(rl.check("k"), false, "spent");
|
||||
rl.refund("k");
|
||||
assert.equal(rl.check("k"), true, "one back");
|
||||
assert.equal(rl.check("k"), false, "and only one");
|
||||
});
|
||||
|
||||
test("refunding every attempt leaves the key spending nothing", () => {
|
||||
// The outage case: every try refunded, so a person retrying through it is
|
||||
// not locked out when the server returns.
|
||||
const rl = new RateLimiter(2, 60_000);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
assert.equal(rl.check("k"), true, `attempt ${i} allowed`);
|
||||
rl.refund("k");
|
||||
}
|
||||
});
|
||||
|
||||
test("a run of real failures still adds up around a refunded one", () => {
|
||||
// Refund takes one attempt back, not the key's whole history -- an outage in
|
||||
// the middle of somebody guessing must not clear what they spent before it.
|
||||
const rl = new RateLimiter(3, 60_000);
|
||||
rl.check("k"); // a wrong password
|
||||
rl.check("k"); // another
|
||||
rl.check("k"); rl.refund("k"); // an outage, given back
|
||||
assert.equal(rl.check("k"), true, "third real attempt");
|
||||
assert.equal(rl.check("k"), false, "and now spent");
|
||||
});
|
||||
|
||||
test("refunding a key that never spent anything is harmless", () => {
|
||||
const rl = new RateLimiter(1, 60_000);
|
||||
rl.refund("never-seen");
|
||||
assert.equal(rl.check("never-seen"), true);
|
||||
});
|
||||
|
||||
test("reset clears the key, refund does not", () => {
|
||||
const rl = new RateLimiter(2, 60_000);
|
||||
rl.check("k");
|
||||
rl.check("k");
|
||||
rl.refund("k");
|
||||
assert.equal(rl.check("k"), true);
|
||||
assert.equal(rl.check("k"), false);
|
||||
rl.reset("k");
|
||||
assert.equal(rl.check("k"), true, "reset is the successful-sign-in case");
|
||||
});
|
||||
Reference in New Issue
Block a user