// Shared types, registration, and cleanup for RateLimiter + ProgressiveRateLimiter. // Used by both production (!dev) and dev (dev) builds — keep tag-free. package mw import ( "context" "crussell/clock" "net" "net/http" "os" "strconv" "sync" "time" "github.com/go-chi/chi/v5/middleware" ) // trustProxyHeaders gates clientIP()'s use of the proxy-set client-IP headers: // CF-Connecting-IP in clientIP(), and (via TrustProxyHeaders) the X-Real-IP // ClientIPFromHeader middleware registered in main.go. It is read once at // startup from the TRUST_PROXY_HEADERS env var and defaults to false. // // nginx (nginx/conf.d/default.conf) resolves the real client IP itself with // the real_ip module (set_real_ip_from + // real_ip_header CF-Connecting-IP), then overwrites both X-Real-IP and // CF-Connecting-IP with the validated $remote_addr — so behind nginx those // headers are the authoritative, unspoofable per-client key. Set // TRUST_PROXY_HEADERS=true for ANY deployment where a trusted proxy (nginx // and/or the Cloudflare edge) sits between clients and this backend and // overwrites these headers itself. It MUST stay false when the backend is // origin-exposed: a client talking directly to the backend could otherwise // rotate X-Real-IP and/or CF-Connecting-IP to bypass per-IP rate limiting. var trustProxyHeaders = func() bool { v, ok := os.LookupEnv("TRUST_PROXY_HEADERS") if !ok { return false } b, err := strconv.ParseBool(v) return err == nil && b }() // TrustProxyHeaders reports whether proxy-set client-IP headers (X-Real-IP, // CF-Connecting-IP) are honored by the rate limiter. main.go uses it to gate // middleware.ClientIPFromHeader("X-Real-IP") on the same flag, so an // origin-exposed backend never registers a middleware that would let a client // forge its own rate-limit key. Both the header trust in ClientIP and the // middleware registration read this single source of truth. func TrustProxyHeaders() bool { return trustProxyHeaders } // ClientIP derives the per-client IP for security-sensitive handlers that need // a client-address key (reservation ipHash, per-IP audit trails) using the // SAME gated resolution as the rate limiter. Priority: // // 1. CF-Connecting-IP header — honored ONLY when TRUST_PROXY_HEADERS=true // (see trustProxyHeaders). A trusted edge (Cloudflare, or nginx whose // real_ip module validated it against the set_real_ip_from ranges) has // already overwritten it with the real client IP, so it is unspoofable // there. Ignored by default because an origin-exposed backend must never // trust a client-controlled value (B7). // 2. middleware.GetClientIP(r.Context()) — the X-Real-IP value nginx sets // from $remote_addr, captured by middleware.ClientIPFromHeader("X-Real-IP") // in main.go. That middleware is registered only when // TRUST_PROXY_HEADERS=true, so it too is trusted solely behind a proxy. // 3. net.SplitHostPort(r.RemoteAddr) / r.RemoteAddr fallback — the actual // TCP peer; the only key source usable when the backend is origin-exposed. func ClientIP(r *http.Request) string { if trustProxyHeaders { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { return ip } } if ip := middleware.GetClientIP(r.Context()); ip != "" { return ip } if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && ip != "" { return ip } return r.RemoteAddr } // RateLimiter implements a simple in-memory rate limiter type RateLimiter struct { requests map[string][]time.Time mu sync.RWMutex limit int window time.Duration } // ProgressiveRateLimiter implements per-IP rate limiting with increasing backoff. // Designed for bot-spam prevention across accounts (not account-specific lockout). type ProgressiveRateLimiter struct { requests map[string]*ipProgressiveState mu sync.RWMutex } type ipProgressiveState struct { // Timestamps of all requests within the tracking window timestamps []time.Time } var ( registeredLimiters []*RateLimiter registeredLimitersMu sync.Mutex ) func registerLimiter(rl *RateLimiter) { registeredLimitersMu.Lock() registeredLimiters = append(registeredLimiters, rl) registeredLimitersMu.Unlock() } // CleanupAllRateLimiters runs Cleanup on every registered RateLimiter. // Called by the centralised jobs scheduler. func CleanupAllRateLimiters(ctx context.Context) (int, error) { registeredLimitersMu.Lock() limiters := make([]*RateLimiter, len(registeredLimiters)) copy(limiters, registeredLimiters) registeredLimitersMu.Unlock() for _, l := range limiters { l.Cleanup() } return 0, nil } // CleanupProgressiveRateLimiter runs Cleanup on the global progressive rate limiter. // Called by the centralised jobs scheduler. func CleanupProgressiveRateLimiter(ctx context.Context) (int, error) { globalProgressiveLimiter.Cleanup() return 0, nil } // Cleanup removes expired entries from the rate limiter map. func (rl *RateLimiter) Cleanup() { rl.mu.Lock() defer rl.mu.Unlock() now := clock.Now() for key, times := range rl.requests { var valid []time.Time for _, t := range times { if now.Sub(t) < rl.window { valid = append(valid, t) } } if len(valid) == 0 { delete(rl.requests, key) } else { rl.requests[key] = valid } } } // Cleanup removes expired entries from the progressive rate limiter map. func (prl *ProgressiveRateLimiter) Cleanup() { prl.mu.Lock() defer prl.mu.Unlock() cutoff := clock.Now().Add(-60 * time.Second) for ip, state := range prl.requests { var valid []time.Time for _, t := range state.timestamps { if t.After(cutoff) { valid = append(valid, t) } } if len(valid) == 0 { delete(prl.requests, ip) } else { state.timestamps = valid } } } var globalProgressiveLimiter = NewProgressiveRateLimiter()