Files
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

140 lines
4.3 KiB
Go

package httpserver
import (
"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.
// - The per-address budget is only as good as the address. That used to be
// net/http's RemoteAddr, which behind a reverse proxy is the proxy on
// every request and makes this budget global. It is now resolved by
// proxyTrust.clientAddr (clientip.go), which reads a forwarded address
// when — and only when — the immediate peer is a configured trusted
// proxy. An unconfigured deployment still gets RemoteAddr, so a proxied
// deployment must set HTTP_TRUSTED_PROXIES for this limit to be per-user.
// - 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)
}
}
}