All checks were successful
check / check (push) Successful in 2m52s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 64 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory: a waiter reaches the guard with its form parsed, so it holds up to the 1 MB body cap for the whole wait, and at flood rates an unbounded queue is worth gigabytes against a 128 MB hashing budget. 64 waiters is 64 MB of committed queue memory, shallow enough that two slots drain a full queue inside the five-second deadline; peak commitment is 128 MB of hashing plus about 66 MB of parsed bodies. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
355 lines
12 KiB
Go
355 lines
12 KiB
Go
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
|
|
|
|
// passwordVerifyMaxWaiters bounds how many requests may be
|
|
// queued for a slot at once. Past it, acquire sheds immediately
|
|
// with 503 instead of joining the queue.
|
|
//
|
|
// The wait bounds how long one request occupies memory; this
|
|
// bounds how many do so at the same time, and without it the
|
|
// 128 MB hashing budget above is the smaller half of the real
|
|
// footprint. A waiter is not free: by the time it reaches the
|
|
// guard its form is parsed, so it holds up to maxFormBodySize —
|
|
// 1 MB — for as long as it waits. At the 400 req/s a saturation
|
|
// attack can offer, an unbounded queue would hold ~2000 of those
|
|
// for the full five seconds, which is gigabytes.
|
|
//
|
|
// Arithmetic: 1 MB a waiter, and the memory committed to the
|
|
// queue is 64 MB, so 64 waiters. Cross-check against the
|
|
// deadline: two slots at the ~27 verifications/s measured on a
|
|
// review host (with the race detector on, so the real rate is
|
|
// higher) drain a full 64-deep queue in about 2.4 s, inside
|
|
// passwordVerifyWait. Queueing deeper would buy memory rather
|
|
// than throughput, because the extra waiters could not be served
|
|
// before their deadline anyway.
|
|
//
|
|
// Peak commitment is therefore 128 MB of Argon2id plus at most
|
|
// 66 MB of parsed forms — 64 queued and the 2 being hashed.
|
|
passwordVerifyMaxWaiters = 64
|
|
|
|
// 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{}
|
|
|
|
// queue holds one token per request waiting for a slot. A token
|
|
// is taken non-blockingly, so a request that finds it full is
|
|
// shed rather than queued, and is given up as soon as the wait
|
|
// ends however it ends.
|
|
queue 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, queue depth and
|
|
// slot wait.
|
|
func newLoginGuard(
|
|
limit int,
|
|
interval time.Duration,
|
|
maxKeys, concurrency, maxWaiters int,
|
|
wait time.Duration,
|
|
) *loginGuard {
|
|
return &loginGuard{
|
|
byUser: make(map[string]*failureWindow),
|
|
byAddr: make(map[string]*failureWindow),
|
|
slots: make(chan struct{}, concurrency),
|
|
queue: make(chan struct{}, maxWaiters),
|
|
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 the queue of waiters is
|
|
// already full, when no slot became available in time, or when 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) {
|
|
// Shedding past the queue depth is what keeps waiting memory
|
|
// bounded; the wait alone only bounds how long one waiter holds
|
|
// its parsed form, not how many hold one at once.
|
|
select {
|
|
case g.queue <- struct{}{}:
|
|
default:
|
|
return nil, false
|
|
}
|
|
|
|
// Held only for the wait. A request that gets a slot gives its
|
|
// queue token back before it starts hashing, so the depth is a
|
|
// bound on waiters rather than on requests in the handler.
|
|
defer func() { <-g.queue }()
|
|
|
|
timer := time.NewTimer(g.wait)
|
|
defer timer.Stop()
|
|
|
|
// The blocking send is deliberate: a receive on a full buffered
|
|
// channel hands the slot straight to the head of the send queue,
|
|
// so slots go out in arrival order and a later arrival cannot
|
|
// barge past a request already waiting.
|
|
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,
|
|
passwordVerifyMaxWaiters,
|
|
passwordVerifyWait,
|
|
)
|
|
})
|
|
|
|
return m.loginGuard
|
|
}
|
|
|
|
// BeginPasswordVerification reserves one of the bounded Argon2id
|
|
// verification slots. It reports false when the queue of waiting
|
|
// requests is already at passwordVerifyMaxWaiters, or when no slot
|
|
// became free within passwordVerifyWait; in either 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
|
|
}
|