test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
Every password-shaped check was mounted bare, so guessing was limited only by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them: - login: 10 failures per account and 50 per address per 15 min, checked before the user lookup and bcrypt; 429 with Retry-After. A success clears the account's count but not the address's. - unknown usernames run a dummy bcrypt compare, so timing no longer says which accounts exist. - register: 10 per address per hour; forgot-password: 5 per address and 3 per email per hour (applied whether or not the email matches); reset: 20 failed tokens per address per 15 min. - Subsonic /rest: same limits as login, counting only wrong credentials, since clients authenticate on every request. Web login, register, reset and forgot-password screens say how long to wait; web and Android carry copy for the rate_limited code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
3.3 KiB
Go
115 lines
3.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeClock lets a test step through a window without sleeping.
|
|
type fakeClock struct{ t time.Time }
|
|
|
|
func (c *fakeClock) now() time.Time { return c.t }
|
|
|
|
func newTestLimiter(max int, window time.Duration) (*AttemptLimiter, *fakeClock) {
|
|
c := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)}
|
|
l := NewAttemptLimiter(max, window)
|
|
l.now = c.now
|
|
return l, c
|
|
}
|
|
|
|
func TestAttemptLimiter_BlocksAtMaxAndReportsWait(t *testing.T) {
|
|
l, c := newTestLimiter(3, 15*time.Minute)
|
|
for i := 0; i < 3; i++ {
|
|
if blocked, _ := l.Blocked("k"); blocked {
|
|
t.Fatalf("blocked after %d attempts, want allowed below max", i)
|
|
}
|
|
l.Record("k")
|
|
}
|
|
c.t = c.t.Add(5 * time.Minute)
|
|
blocked, wait := l.Blocked("k")
|
|
if !blocked {
|
|
t.Fatal("not blocked after max attempts")
|
|
}
|
|
if wait != 10*time.Minute {
|
|
t.Errorf("wait = %v, want the 10m left in the window", wait)
|
|
}
|
|
}
|
|
|
|
func TestAttemptLimiter_WindowExpiryClears(t *testing.T) {
|
|
l, c := newTestLimiter(1, time.Minute)
|
|
l.Record("k")
|
|
if blocked, _ := l.Blocked("k"); !blocked {
|
|
t.Fatal("want blocked inside the window")
|
|
}
|
|
c.t = c.t.Add(time.Minute)
|
|
if blocked, _ := l.Blocked("k"); blocked {
|
|
t.Fatal("still blocked once the window has passed")
|
|
}
|
|
}
|
|
|
|
func TestAttemptLimiter_KeysAreIndependentAndResetClears(t *testing.T) {
|
|
l, _ := newTestLimiter(1, time.Minute)
|
|
l.Record("a")
|
|
if blocked, _ := l.Blocked("b"); blocked {
|
|
t.Fatal("one key's attempts blocked another")
|
|
}
|
|
l.Reset("a")
|
|
if blocked, _ := l.Blocked("a"); blocked {
|
|
t.Fatal("Reset did not clear the key")
|
|
}
|
|
}
|
|
|
|
func TestAttemptLimiter_SweepDropsExpiredKeys(t *testing.T) {
|
|
l, c := newTestLimiter(5, time.Minute)
|
|
// One short of the floor: no sweep yet, however stale these become.
|
|
for i := 0; i < sweepFloor-1; i++ {
|
|
l.Record("k" + time.Duration(i).String())
|
|
}
|
|
c.t = c.t.Add(2 * time.Minute)
|
|
l.Record("fresh") // reaches the floor and triggers a sweep
|
|
if n := len(l.buckets); n != 1 {
|
|
t.Errorf("buckets after sweep = %d, want only the fresh key", n)
|
|
}
|
|
}
|
|
|
|
func TestAttemptLimiter_NilAndEmptyKeyAreNoOps(t *testing.T) {
|
|
var l *AttemptLimiter
|
|
l.Record("k")
|
|
if blocked, _ := l.Blocked("k"); blocked {
|
|
t.Error("nil limiter blocked")
|
|
}
|
|
real, _ := newTestLimiter(1, time.Minute)
|
|
real.Record("")
|
|
if blocked, _ := real.Blocked(""); blocked {
|
|
t.Error("empty key was counted")
|
|
}
|
|
}
|
|
|
|
func TestLoginGuard_AccountLimitSpansAddressesAndFoldsCase(t *testing.T) {
|
|
g := NewLoginGuard()
|
|
for i := 0; i < loginAccountMax; i++ {
|
|
g.Fail("Alice", "10.0.0."+string(rune('0'+i%10)))
|
|
}
|
|
if blocked, _ := g.Blocked("alice", "192.0.2.1"); !blocked {
|
|
t.Fatal("account limit should hold from a fresh address and any capitalisation")
|
|
}
|
|
g.Succeed("ALICE")
|
|
if blocked, _ := g.Blocked("alice", "192.0.2.1"); blocked {
|
|
t.Fatal("Succeed should clear the account's failures")
|
|
}
|
|
}
|
|
|
|
func TestLoginGuard_AddressLimitSpansAccounts(t *testing.T) {
|
|
g := NewLoginGuard()
|
|
for i := 0; i < loginAddressMax; i++ {
|
|
g.Fail("user"+time.Duration(i).String(), "203.0.113.9")
|
|
}
|
|
if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked {
|
|
t.Fatal("address that sprayed many accounts should be blocked")
|
|
}
|
|
g.Succeed("someone-new")
|
|
if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked {
|
|
t.Fatal("a success must not clear the address count")
|
|
}
|
|
}
|