// clientIP extracts the client IP for rate-limit keying. // // Trust boundary (issue #280): the bucket key MUST NOT come from any header a // client can influence. The previous rightmost-X-Forwarded-For scheme (#85) // assumed Traefik appends the real client IP, but the deployed ingress does // not rewrite XFF, so a client rotating its own XFF value got a fresh bucket // per request and the limit was unenforceable (pentest H1: 6x201 across 8 // rotating-XFF creates). // // Default: key on the actual peer address (RemoteAddr) only. Behind any // reverse proxy this is the proxy's address, so all clients share one bucket // per endpoint — coarse, but safe. // // Proxy-honoring mode: a deployment in front of a proxy that OVERWRITES (not // appends to) a client-IP header can set PALETTE_TRUSTED_IP_HEADER (e.g. // CF-Connecting-IP when Cloudflare is the ingress; Cloudflare strips any // client-supplied value). The header is honored ONLY when explicitly // configured at startup, and X-Forwarded-For / X-Real-Ip are never trusted. package api import ( "net" "net/http" "sync" ) var ( trustedIPMu sync.RWMutex trustedIPHeader string // empty = never trust any client-IP header ) // SetTrustedIPHeader configures the single proxy-controlled header whose // value may key rate-limit buckets. Called at startup; tests may reset it. func SetTrustedIPHeader(name string) { trustedIPMu.Lock() defer trustedIPMu.Unlock() trustedIPHeader = name } func getTrustedIPHeader() string { trustedIPMu.RLock() defer trustedIPMu.RUnlock() return trustedIPHeader } func clientIP(r *http.Request) string { if name := getTrustedIPHeader(); name != "" { if v := r.Header.Get(name); v != "" { return v } } host := r.RemoteAddr if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { host = h } return host }