fix(web): count login attempts per address and while they are checked

The login limiter counted failures per (address, username) only after
the check finished, so attempts sent together all passed before the
first failure was recorded, and one address could own every record of
the bounded table and push out the one holding its own failures.

An admitted attempt now holds a slot until it reports back, and an
address is blocked as a whole after five times the per-username limit
spread over any names, which also caps the records one peer can own.
This commit is contained in:
MHSanaei
2026-10-31 09:04:23 +00:00
parent 661a169045
commit 8e8541a5d8
2 changed files with 130 additions and 33 deletions
@@ -118,3 +118,45 @@ func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
t.Fatal("different username should not be blocked")
}
}
// Without a per-address bound, one address can fill the table with its own blocked
// records and evict the record holding its admin failures, resetting the count.
func TestLoginLimiterBlocksAnAddressSpreadingFailuresOverUsernames(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for i := range 5 * loginLimitIPFactor {
if _, ok := limiter.allow("192.0.2.10", "decoy-"+strconv.Itoa(i)); !ok {
t.Fatalf("attempt %d refused before the address limit", i+1)
}
limiter.registerFailure("192.0.2.10", "decoy-"+strconv.Itoa(i))
}
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
t.Fatal("an address past its failure budget may still try the admin account")
}
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
t.Fatal("another address was blocked too")
}
}
// The count must cover attempts admitted and still being checked: bcrypt is slow, and
// a burst sent at once would otherwise all pass before the first failure is recorded.
func TestLoginLimiterCountsAttemptsStillBeingChecked(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for i := range 5 {
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
t.Fatalf("attempt %d of a burst refused", i+1)
}
}
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
t.Fatal("a sixth concurrent attempt was admitted")
}
now = now.Add(20 * time.Minute)
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
t.Fatal("attempts that never reported back still block after the window and cooldown")
}
}