fix #280: key rate-limit buckets on peer address, not client XFF
The deployed ingress does not append the peer to X-Forwarded-For, so the rightmost XFF entry was fully client-controlled and rotating the header gave a fresh rate-limit bucket per request (pentest: 8 creates, 6x201). clientIP() now defaults to the peer (RemoteAddr) and never trusts client-supplied headers. Per-client granularity is restored opt-in via PALETTE_CLIENT_IP_HEADER, honored only when the immediate peer is inside PALETTE_TRUSTED_PROXIES (default loopback + RFC1918) and the value parses as an IP.
This commit is contained in:
+61
-20
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -48,33 +50,72 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// clientIP extracts the client IP for rate-limit keying (#85).
|
||||
// clientIP extracts the client IP for rate-limit keying (#85, #280).
|
||||
//
|
||||
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
|
||||
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
|
||||
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
|
||||
// proxy observed and is unspoofable by the client (a client-supplied fake
|
||||
// entry only lands on the LEFT and is ignored). This matches chi's
|
||||
// middleware.RealIP semantics for a single trusted proxy hop.
|
||||
// Trust boundary (revised in #280): the deployed ingress does NOT append the
|
||||
// peer address to X-Forwarded-For, so every XFF entry (and X-Real-Ip) is
|
||||
// client-controlled. Keying on any client-supplied header lets an attacker
|
||||
// rotate the header per request and get a fresh rate-limit bucket every
|
||||
// time. The bucket key therefore defaults to the PEER address (RemoteAddr),
|
||||
// which the client cannot influence.
|
||||
//
|
||||
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
|
||||
// reachable deployments must NOT expose the app to untrusted networks
|
||||
// without a proxy in front, or attackers could forge the rightmost entry.
|
||||
// Deployments that pin their proxy chain can restore per-client granularity:
|
||||
// set PALETTE_CLIENT_IP_HEADER (e.g. "CF-Connecting-IP" once the proxy chain
|
||||
// is pinned, e.g. Traefik forwardedHeaders.trustedIPs limited to Cloudflare
|
||||
// ranges or an origin firewall locked to CF) and the header value is honored
|
||||
// ONLY when the immediate peer (RemoteAddr) is inside PALETTE_TRUSTED_PROXIES
|
||||
// (default: loopback + RFC1918 private ranges, i.e. the in-cluster Traefik
|
||||
// hop). A public peer never triggers header trust, and the header value must
|
||||
// parse as an IP.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.LastIndex(xff, ","); i >= 0 {
|
||||
return strings.TrimSpace(xff[i+1:])
|
||||
peer := hostOnly(r.RemoteAddr)
|
||||
if hdr := os.Getenv("PALETTE_CLIENT_IP_HEADER"); hdr != "" && trustedProxy(peer) {
|
||||
if v := strings.TrimSpace(r.Header.Get(hdr)); net.ParseIP(v) != nil {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
|
||||
return strings.TrimSpace(xr)
|
||||
return peer
|
||||
}
|
||||
|
||||
// trustedProxy reports whether peer (an IP without port) falls inside any of
|
||||
// the configured trusted proxy CIDRs (PALETTE_TRUSTED_PROXIES, default
|
||||
// loopback + RFC1918 private ranges). Parsed once and cached.
|
||||
func trustedProxy(peer string) bool {
|
||||
trustedOnce.Do(func() {
|
||||
spec := os.Getenv("PALETTE_TRUSTED_PROXIES")
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
spec = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
}
|
||||
for _, c := range strings.Split(spec, ",") {
|
||||
if _, cidr, err := net.ParseCIDR(strings.TrimSpace(c)); err == nil {
|
||||
trustedCIDRs = append(trustedCIDRs, cidr)
|
||||
}
|
||||
}
|
||||
})
|
||||
ip := net.ParseIP(peer)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
host := r.RemoteAddr
|
||||
if i := strings.LastIndex(host, ":"); i > 0 {
|
||||
host = host[:i]
|
||||
for _, cidr := range trustedCIDRs {
|
||||
if cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return host
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
trustedOnce sync.Once
|
||||
trustedCIDRs []*net.IPNet
|
||||
)
|
||||
|
||||
// hostOnly strips the port from a host:port address.
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
return strings.TrimSpace(host)
|
||||
}
|
||||
|
||||
var globalLimiter = newLimiter()
|
||||
|
||||
Reference in New Issue
Block a user