package httpserver import ( "net" "net/http" "strings" "sync" "time" ) // Login rate limiting. // // WHAT THIS IS: a fixed-window counter of *failed* sign-in attempts, held in // this process's memory, keyed by client address and by email address. It turns // online password guessing from "as fast as the server can hash" into a handful // of tries per window, which is the whole job. // // WHAT THIS IS NOT, and the limitation to carry into production: // // - It is per-process. Two API instances behind a load balancer each allow // the full budget, so the effective limit is the limit times the instance // count, and a restart clears every counter. A deployment with more than // one instance needs shared state — Redis, or the database — and this // package is the seam where that goes: attemptLimiter is an implementation // detail behind Allow/Fail/Reset. // - It trusts net/http's RemoteAddr for the client address. Behind a reverse // proxy every request appears to come from the proxy, so the per-address // budget becomes global. Reading X-Forwarded-For instead would be worse, // not better, until there is a trusted-proxy list to validate it against — // a client can send that header itself and mint a fresh budget per request. // Deploying behind a proxy means adding that list first. // - It is memory-bounded by pruning, not by a hard cap, so a flood from many // distinct addresses grows the map until the next prune. // // Only failures are counted. A correct password resets the email's counter, so // a person who mistypes twice and then succeeds is not left carrying a penalty. const ( // loginAttemptLimit is per email address per window. Five is comfortably // above human error and far below useful for guessing. loginAttemptLimit = 5 // loginAddressLimit is per client address per window. Higher than the // per-email limit because one address legitimately covers a whole office // behind NAT, where several people may each fumble a password. loginAddressLimit = 20 // loginAttemptWindow is how long a counter lives. loginAttemptWindow = 15 * time.Minute ) // attemptLimiter counts failures per key within a fixed window. type attemptLimiter struct { mu sync.Mutex limit int window time.Duration now func() time.Time buckets map[string]*attemptBucket } type attemptBucket struct { count int resetAt time.Time } func newAttemptLimiter(limit int, window time.Duration, now func() time.Time) *attemptLimiter { if now == nil { now = time.Now } return &attemptLimiter{ limit: limit, window: window, now: now, buckets: make(map[string]*attemptBucket), } } // Allow reports whether another attempt may be made, and if not, how long the // caller should wait. It records nothing: only Fail does. // // Checking and recording are separate so a *successful* login never consumes // budget — the check happens before the password is verified, and the recording // only if it turns out to be wrong. func (l *attemptLimiter) Allow(key string) (bool, time.Duration) { if key == "" { return true, 0 } l.mu.Lock() defer l.mu.Unlock() b, ok := l.buckets[key] now := l.now() if !ok || !now.Before(b.resetAt) { return true, 0 } if b.count < l.limit { return true, 0 } return false, b.resetAt.Sub(now) } // Fail records one failed attempt. func (l *attemptLimiter) Fail(key string) { if key == "" { return } l.mu.Lock() defer l.mu.Unlock() now := l.now() l.pruneLocked(now) b, ok := l.buckets[key] if !ok || !now.Before(b.resetAt) { l.buckets[key] = &attemptBucket{count: 1, resetAt: now.Add(l.window)} return } b.count++ } // Reset clears a key's counter. Called on a successful sign-in. func (l *attemptLimiter) Reset(key string) { if key == "" { return } l.mu.Lock() defer l.mu.Unlock() delete(l.buckets, key) } // pruneMinimum is the size below which pruning is not worth the walk. const pruneMinimum = 1024 // pruneLocked drops expired buckets once the map is large enough to be worth // walking. Called from Fail, which is the only path that grows the map. func (l *attemptLimiter) pruneLocked(now time.Time) { if len(l.buckets) < pruneMinimum { return } for key, b := range l.buckets { if !now.Before(b.resetAt) { delete(l.buckets, key) } } } // clientAddr is the key for per-address limiting. // // The port is stripped: a browser uses a new source port for every connection, // so keying on host:port would give each attempt its own budget and limit // nothing at all. func clientAddr(r *http.Request) string { host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) if err != nil { return strings.TrimSpace(r.RemoteAddr) } return host }