package middleware import ( "context" "crypto/sha256" "encoding/hex" "net/http" "sync" "time" ) const ( // loginFailureMaxKeys bounds how many distinct failure counters // each of the guard's two key sets holds. The submitted username // is part of a key, so the key set is attacker-influenced and // needs a hard cap or the limiter becomes the memory // amplification surface it exists to protect. // // A single-admin deployment has a handful of legitimate (client, // username) pairs, so 1024 is three orders of magnitude of // headroom before a real operator can be pushed onto the // fallback. It costs little: a counter is a ~64-byte key string, // a 32-byte window and map overhead, call it 170 bytes, so both // key sets full is 2 * 1024 * 170 bytes, under 0.4 MB. loginFailureMaxKeys = 1024 // passwordVerifyConcurrency bounds how many Argon2id // verifications may run at once across every password-verifying // endpoint. Because credentials are now verified before any // limiter budget is spent, an attacker can force one hash per // request, and each hash allocates argon2Memory — 64 MB. Two // slots commit at most 128 MB to password hashing, which fits // inside the smallest container this service is realistically // given alongside its own working set; four would commit 256 MB // and crowd it. A single-admin product needs no concurrent // logins at all, so the second slot exists only so that one // stalled request does not serialise the endpoint. passwordVerifyConcurrency = 2 // passwordVerifyWait is how long a request waits for a // verification slot before it is answered 503. Slots are handed // out in arrival order, so a legitimate request queues behind // the requests already waiting rather than behind the flood as a // whole. The wait is well inside the 60s request timeout. passwordVerifyWait = 5 * time.Second // failureKeyHashBytes is how much of the username digest goes // into a failure key. 64 bits over at most loginFailureMaxKeys // live keys makes a collision negligible, and a collision would // only merge two usernames' failure counters, which throttles // sooner rather than later. failureKeyHashBytes = 8 ) // failureWindow counts failed credential verifications for one // bucket, and records when that count lapses. type failureWindow struct { count int resetAt time.Time } // loginGuard is what replaced the pre-emptive rate limiter on the // login POST. // // A limiter that spends budget on arrival cannot protect a // single-admin product: behind the reverse proxy the deployment // requires, with TRUSTED_PROXIES unset, every client keys on the // proxy, so a stranger trickling five POSTs a minute keeps the one // bucket full and the operator's own correct password is answered 429 // forever. There is no second administrative path. // // So budget is spent only by a FAILED verification. A correct // password is never throttled, whatever the counters say, which is // the only shape that guarantees the operator can get in. Two // consequences follow and are handled here: // // - Every login request now costs an Argon2id hash, so the number // running concurrently is bounded by slots. Without that bound // this trades an admin lockout for memory exhaustion, which is // strictly worse. // - Counting per (client, username) makes the key set // attacker-influenced, so both key sets are capped. Beyond the // per-username cap, failures fall back to a counter keyed on the // client alone; beyond that cap too, a failure is answered as // throttled without being recorded, since refusing to answer a // wrong password costs the operator nothing. type loginGuard struct { mu sync.Mutex byUser map[string]*failureWindow byAddr map[string]*failureWindow slots chan struct{} limit int interval time.Duration maxKeys int wait time.Duration // now is time.Now outside tests. now func() time.Time } // newLoginGuard builds a guard with the given failure limit per // interval, key-set cap, verification concurrency and slot wait. func newLoginGuard( limit int, interval time.Duration, maxKeys, concurrency int, wait time.Duration, ) *loginGuard { return &loginGuard{ byUser: make(map[string]*failureWindow), byAddr: make(map[string]*failureWindow), slots: make(chan struct{}, concurrency), limit: limit, interval: interval, maxKeys: maxKeys, wait: wait, now: time.Now, } } // acquire reserves a verification slot, waiting up to the guard's // wait for one. It reports false when none became available or the // request was cancelled first; the caller must then answer 503 // without verifying anything. The returned function releases the // slot and must be called exactly once. func (g *loginGuard) acquire(ctx context.Context) (func(), bool) { timer := time.NewTimer(g.wait) defer timer.Stop() select { case g.slots <- struct{}{}: return func() { <-g.slots }, true case <-timer.C: return nil, false case <-ctx.Done(): return nil, false } } // fail records one failed credential verification by clientKey // against username, and reports whether this client has now spent // its failure budget and should be answered 429. func (g *loginGuard) fail(clientKey, username string) bool { g.mu.Lock() defer g.mu.Unlock() now := g.now() window := g.window( g.byUser, userFailureKey(clientKey, username), now, ) if window == nil { window = g.window(g.byAddr, clientKey, now) } if window == nil { // Both key sets are full and neither already tracks this // client, so nothing can be counted without unbounded // growth. Answering the failure as throttled is the safe // direction: it never touches a correct password. return true } window.count++ return window.count >= g.limit } // succeed forgives clientKey's failures against username. A correct // password clears the counters, so an operator who mistypes several // times and then gets it right is not throttled afterwards. func (g *loginGuard) succeed(clientKey, username string) { g.mu.Lock() defer g.mu.Unlock() delete(g.byUser, userFailureKey(clientKey, username)) delete(g.byAddr, clientKey) } // window returns the live counter for key in set, resetting a lapsed // one and creating a missing one when the cap allows. It returns nil // only when key is absent and set is full even after lapsed entries // are swept. func (g *loginGuard) window( set map[string]*failureWindow, key string, now time.Time, ) *failureWindow { window, ok := set[key] if ok { if !now.Before(window.resetAt) { window.count = 0 window.resetAt = now.Add(g.interval) } return window } if len(set) >= g.maxKeys { sweepLapsed(set, now) } if len(set) >= g.maxKeys { return nil } window = &failureWindow{resetAt: now.Add(g.interval)} set[key] = window return window } // sweepLapsed drops counters whose interval has elapsed. func sweepLapsed(set map[string]*failureWindow, now time.Time) { for key, window := range set { if !now.Before(window.resetAt) { delete(set, key) } } } // userFailureKey identifies one (client, submitted username) pair. // The username is hashed rather than embedded: a submitted username // is attacker-controlled text of attacker-chosen length, and hashing // makes every key the same size whatever was sent. func userFailureKey(clientKey, username string) string { sum := sha256.Sum256([]byte(username)) return clientKey + "|" + hex.EncodeToString(sum[:failureKeyHashBytes]) } // guard returns the middleware's login guard, building it on first // use so that every construction path — fx and the test constructor // alike — gets one. func (m *Middleware) guard() *loginGuard { m.loginGuardOnce.Do(func() { m.loginGuard = newLoginGuard( loginRateLimit, loginRateInterval, loginFailureMaxKeys, passwordVerifyConcurrency, passwordVerifyWait, ) }) return m.loginGuard } // BeginPasswordVerification reserves one of the bounded Argon2id // verification slots. It reports false when none became free within // passwordVerifyWait, in which case the caller must answer 503 and // must not verify a password. The returned function releases the // slot and must be called exactly once. // // Every endpoint that hashes a password on request must go through // this, or the bound has a hole: the memory is committed per hash, // not per endpoint. func (m *Middleware) BeginPasswordVerification( ctx context.Context, ) (func(), bool) { return m.guard().acquire(ctx) } // RecordLoginFailure counts a failed credential verification for the // request's client against the submitted username, and reports // whether the response should be 429 rather than 401. func (m *Middleware) RecordLoginFailure( r *http.Request, username string, ) bool { throttled := m.guard().fail(m.clientKey(r), username) if throttled { m.log.Warn( "login failure limit exceeded", "path", r.URL.Path, ) } return throttled } // ForgiveLoginFailures clears the failure counters for the request's // client and the submitted username after a successful // authentication. func (m *Middleware) ForgiveLoginFailures( r *http.Request, username string, ) { m.guard().succeed(m.clientKey(r), username) } // LoginFailureInterval is how long a spent login failure budget // takes to refill, which is what a throttled login answers as // Retry-After. func (m *Middleware) LoginFailureInterval() time.Duration { return m.guard().interval }