clientIP() keyed rate-limit buckets on the rightmost X-Forwarded-For entry, assuming traefik appends the real client IP. The deployed ingress does not rewrite XFF, so rotating the header gave a fresh bucket per request (pentest H1: 8 creates with rotating XFF -> 6x201). Now the bucket keys on the actual peer address (RemoteAddr) by default; every client-supplied IP header is ignored. Deployments whose ingress overwrites a client-IP header can opt in via PALETTE_TRUSTED_IP_HEADER (e.g. CF-Connecting-IP behind Cloudflare) to restore per-client limits. Adds tests: rotating XFF no longer resets the bucket; the trusted header is honored only when explicitly configured.
58 lines
1.8 KiB
Go
58 lines
1.8 KiB
Go
// 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
|
|
}
|