// Abuse protection for the unauthenticated auth endpoints: a fixed-window // counter per IP plus consecutive-failure lockout per (IP, email) on login. // // Review SEC-1: the login endpoint and its failure lockout apply to EVERY // peer, including loopback. In same-host non-Docker deployments the Next // server reaches Go over localhost, and exempting that path removed all // throttling from real browser logins (unlimited online password guessing). // Signup keeps the loopback exemption: it exists for same-host consumers of // the 127.0.0.1 binding, and a forwarded identity never earns it (SEC-2). // // Review AUTH-RATE: keying strictly on the direct peer made every browser // behind the Next proxy share one bucket (in Compose, Go's peer is always // the web container). The trusted Next server therefore forwards the browser // IP it observed, signed with HMAC-SHA256(secret, ip|unix_ts) under the // shared auth secret (web/lib/api.ts, web/auth.ts), with a 60s replay // window. Go only accepts the forwarded identity when the signature and // timestamp verify, so hostile clients cannot forge it; unsigned or invalid // values fall back to the direct peer. Next forwards only X-Real-IP (never // X-Forwarded-For) and only when TRUSTED_PROXY=1 pins the proxy that // overwrites that header — the shipped nginx template does. package auth import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "github.com/andrew/go-computer/internal/config" "net" "net/http" "sort" "strconv" "sync" "time" ) const ( loginIPLimit = 20 // logins per IP per window loginIPWindow = time.Minute // signupIPLimit = 10 // signups per IP per window signupIPWindow = time.Hour // failureThreshold = 5 // consecutive failures before lockout failureLockout = 15 * time.Minute failureWindow = time.Hour // failures older than this are forgotten // limiterMaxEntries is the soft cap per store. Review SEC-03: the failure // tracker previously never evicted sub-threshold entries (zero until), so // cycling unique emails grew the map without bound. Every mutator now // sweeps once the cap is exceeded. limiterMaxEntries = 4096 ) type counter struct { count int reset time.Time until time.Time // lockout expiry (failure tracker only) } type limiterStore struct { mu sync.Mutex m map[string]*counter } func newLimiterStore() *limiterStore { return &limiterStore{m: map[string]*counter{}} } // sweep bounds memory (review SEC-03). Callers hold s.mu. Pass 1 drops // entries whose window — and lockout, for the failure tracker — have expired. // If a burst of still-live keys (e.g. unique-email login failures inside the // failure window) keeps the store oversized, pass 2 evicts the oldest-tracked // non-locked entries until the cap holds again; active lockouts are left // alone so an eviction can never shorten a lockout. func (s *limiterStore) sweep(now time.Time) { if len(s.m) <= limiterMaxEntries { return } for k, c := range s.m { if now.After(c.reset) && (c.until.IsZero() || now.After(c.until)) { delete(s.m, k) } } if len(s.m) <= limiterMaxEntries { return } keys := make([]string, 0, len(s.m)) for k, c := range s.m { if c.until.IsZero() || now.After(c.until) { keys = append(keys, k) } } sort.Slice(keys, func(i, j int) bool { return s.m[keys[i]].reset.Before(s.m[keys[j]].reset) }) for _, k := range keys[:min(len(keys), len(s.m)-limiterMaxEntries)] { delete(s.m, k) } } // allow reports whether key is within limit for the window starting at first // use. Expiry returns the remaining time until the window resets so callers // can send an accurate Retry-After header (review ERR-3). func (s *limiterStore) allow(key string, limit int, window time.Duration, now time.Time) (bool, time.Duration) { s.mu.Lock() defer s.mu.Unlock() s.sweep(now) c := s.m[key] if c == nil || now.After(c.reset) { s.m[key] = &counter{count: 1, reset: now.Add(window)} return true, 0 } c.count++ retry := c.reset.Sub(now) if retry < 0 { retry = 0 } return c.count <= limit, retry.Truncate(time.Second) + time.Second } func (s *limiterStore) locked(key string, now time.Time) (time.Duration, bool) { s.mu.Lock() defer s.mu.Unlock() s.sweep(now) c := s.m[key] if c == nil || c.until.IsZero() { return 0, false } if now.After(c.until) { delete(s.m, key) return 0, false } // Consistent clock (review SEC-03): derive the retry delay from the // supplied now rather than mixing in time.Until. return c.until.Sub(now).Truncate(time.Second) + time.Second, true } func (s *limiterStore) noteFailure(key string, now time.Time) { s.mu.Lock() defer s.mu.Unlock() s.sweep(now) c := s.m[key] if c == nil || now.After(c.reset) { c = &counter{reset: now.Add(failureWindow)} s.m[key] = c } c.count++ if c.count >= failureThreshold { c.until = now.Add(failureLockout) } } func (s *limiterStore) noteSuccess(key string) { s.mu.Lock() defer s.mu.Unlock() delete(s.m, key) } var ( loginIPs = newLimiterStore() signupIPs = newLimiterStore() loginFailures = newLimiterStore() ) // ResetRateLimits clears all limiter state. Tests only: the stores are // process-global, and integration harnesses reset them per test server so // sequential tests do not inherit each other's buckets (the login endpoint // has no loopback exemption since review SEC-1). func ResetRateLimits() { for _, s := range []*limiterStore{loginIPs, signupIPs, loginFailures} { s.mu.Lock() s.m = map[string]*counter{} s.mu.Unlock() } } // resetRateLimits clears all limiter state. Tests only. func resetRateLimits() { ResetRateLimits() } func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // Headers carrying the proxy-attested browser IP (review AUTH-RATE). The // signature is HMAC-SHA256(secret, ip|unix_ts) in hex, computed by the // trusted Next server; anything else falls back to the direct peer address. // The timestamp bounds replay of a captured header pair to ~60s (review // SEC-2). Next only forwards an IP when TRUSTED_PROXY=1 pins the boundary // that overwrites X-Real-IP (the shipped nginx template does). const ( headerClientIP = "X-Computer-Client-Ip" headerClientTs = "X-Computer-Client-Ts" headerClientSig = "X-Computer-Client-Sig" // forwardSkew bounds clock skew and replay of the signed identity. forwardSkew = 60 * time.Second ) func SignClientIP(secret, ip string, ts int64) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10))) return hex.EncodeToString(mac.Sum(nil)) } func signedForwardIP(r *http.Request) (string, bool) { ip := r.Header.Get(headerClientIP) sig := r.Header.Get(headerClientSig) tsRaw := r.Header.Get(headerClientTs) if ip == "" || sig == "" || tsRaw == "" || net.ParseIP(ip) == nil { return "", false } ts, err := strconv.ParseInt(tsRaw, 10, 64) if err != nil { return "", false } now := time.Now().Unix() if ts > now+int64(forwardSkew/time.Second) || ts < now-int64(forwardSkew/time.Second) { return "", false } secret := config.Load().Secret() if secret == "" { return "", false } if !hmac.Equal([]byte(SignClientIP(secret, ip, ts)), []byte(sig)) { return "", false } return ip, true } // EffectiveClientIP is the identity rate limiting buckets on: the signed // forwarded browser IP when the trusted proxy attested one, else the direct // peer. Never trust arbitrary forwarded headers here. func EffectiveClientIP(r *http.Request) string { if ip, ok := signedForwardIP(r); ok { return ip } return clientIP(r) } // isLoopback reports whether the DIRECT peer is localhost. A forwarded // identity — even a signed loopback one — never earns the exemption (review // SEC-2): the exemption exists for same-host consumers of the 127.0.0.1 // binding, not for anything arriving through a proxy. Plain X-Forwarded-For // is client-controlled and never trusted. func isLoopback(r *http.Request) bool { if _, forwarded := signedForwardIP(r); forwarded { return false } ip := net.ParseIP(clientIP(r)) return ip != nil && ip.IsLoopback() } func tooManyRequests(w http.ResponseWriter, retry time.Duration) { w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds()))) failure(w, http.StatusTooManyRequests, "Too many attempts, slow down") } // RateLimitLogin caps login attempts per effective client IP for every peer // (review SEC-1: loopback is NOT exempt on the login endpoint). func RateLimitLogin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Review ERR-3: Retry-After carries the remaining window time, not // the full window. if ok, retry := loginIPs.allow(EffectiveClientIP(r), loginIPLimit, loginIPWindow, time.Now()); !ok { tooManyRequests(w, retry) return } next(w, r) } } // RateLimitSignup caps account creation per effective client IP for // non-loopback peers. func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !isLoopback(r) { if ok, retry := signupIPs.allow(EffectiveClientIP(r), signupIPLimit, signupIPWindow, time.Now()); !ok { tooManyRequests(w, retry) return } } next(w, r) } } // NewLimiterStore creates a new in-memory rate limiter store. Exported so // callers (e.g. public endpoints) can have independent buckets. func NewLimiterStore() *limiterStore { return newLimiterStore() } // Limiter wraps a handler with a simple per-IP fixed-window rate limit. // Uses the direct peer IP (no forwarded headers) and does not exempt // loopback — public endpoints apply to every peer. func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) if ok, retry := s.allow(ip, limit, window, time.Now()); !ok { tooManyRequests(w, retry) return } next(w, r) } } }