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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -48,33 +50,72 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
|
|||||||
return true
|
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
|
// Trust boundary (revised in #280): the deployed ingress does NOT append the
|
||||||
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
|
// peer address to X-Forwarded-For, so every XFF entry (and X-Real-Ip) is
|
||||||
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
|
// client-controlled. Keying on any client-supplied header lets an attacker
|
||||||
// proxy observed and is unspoofable by the client (a client-supplied fake
|
// rotate the header per request and get a fresh rate-limit bucket every
|
||||||
// entry only lands on the LEFT and is ignored). This matches chi's
|
// time. The bucket key therefore defaults to the PEER address (RemoteAddr),
|
||||||
// middleware.RealIP semantics for a single trusted proxy hop.
|
// which the client cannot influence.
|
||||||
//
|
//
|
||||||
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
|
// Deployments that pin their proxy chain can restore per-client granularity:
|
||||||
// reachable deployments must NOT expose the app to untrusted networks
|
// set PALETTE_CLIENT_IP_HEADER (e.g. "CF-Connecting-IP" once the proxy chain
|
||||||
// without a proxy in front, or attackers could forge the rightmost entry.
|
// 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 {
|
func clientIP(r *http.Request) string {
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
peer := hostOnly(r.RemoteAddr)
|
||||||
if i := strings.LastIndex(xff, ","); i >= 0 {
|
if hdr := os.Getenv("PALETTE_CLIENT_IP_HEADER"); hdr != "" && trustedProxy(peer) {
|
||||||
return strings.TrimSpace(xff[i+1:])
|
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 peer
|
||||||
return strings.TrimSpace(xr)
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
}
|
}
|
||||||
host := r.RemoteAddr
|
for _, c := range strings.Split(spec, ",") {
|
||||||
if i := strings.LastIndex(host, ":"); i > 0 {
|
if _, cidr, err := net.ParseCIDR(strings.TrimSpace(c)); err == nil {
|
||||||
host = host[:i]
|
trustedCIDRs = append(trustedCIDRs, cidr)
|
||||||
}
|
}
|
||||||
return host
|
}
|
||||||
|
})
|
||||||
|
ip := net.ParseIP(peer)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, cidr := range trustedCIDRs {
|
||||||
|
if cidr.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
var globalLimiter = newLimiter()
|
||||||
|
|||||||
@@ -1,85 +1,135 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
|
// Issues #85 + #280: the rate limit bucket key must NEVER be derived from a
|
||||||
// (appended by the trusted Traefik proxy), never the raw/leftmost header
|
// client-controlled header. The deployed ingress does not append the peer
|
||||||
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
|
// address to X-Forwarded-For, so every XFF entry is attacker-controlled and
|
||||||
// limit or rotate buckets.
|
// an attacker can rotate the header per request to get a fresh bucket. The
|
||||||
|
// key defaults to the peer (RemoteAddr); an opt-in trusted header is honored
|
||||||
|
// only when the immediate peer is a trusted proxy.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClientIPTakesRightmostXFF(t *testing.T) {
|
// resetTrustedProxyCache clears the sync.Once-cached CIDR list so each test
|
||||||
r := httptest.NewRequest("POST", "/", nil)
|
// re-parses PALETTE_TRUSTED_PROXIES under its own env.
|
||||||
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
|
func resetTrustedProxyCache(t *testing.T) {
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
|
t.Helper()
|
||||||
if got := clientIP(r); got != "203.0.113.9" {
|
t.Cleanup(func() {
|
||||||
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
|
trustedOnce = sync.Once{}
|
||||||
}
|
trustedCIDRs = nil
|
||||||
|
})
|
||||||
|
trustedOnce = sync.Once{}
|
||||||
|
trustedCIDRs = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientIPXRealIPFallback(t *testing.T) {
|
// Rotating XFF must not create fresh buckets: all requests share the peer's
|
||||||
r := httptest.NewRequest("POST", "/", nil)
|
// bucket. This is the #280 pentest repro (8 creates, 6x201 before the fix).
|
||||||
r.RemoteAddr = "10.42.0.7:51000"
|
func TestRateLimitRotatingXFFSharesPeerBucket(t *testing.T) {
|
||||||
r.Header.Set("X-Real-Ip", "203.0.113.10")
|
|
||||||
if got := clientIP(r); got != "203.0.113.10" {
|
|
||||||
t.Fatalf("clientIP = %q, want 203.0.113.10", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientIPDirectFallback(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("POST", "/", nil)
|
|
||||||
r.RemoteAddr = "198.51.100.5:51000"
|
|
||||||
if got := clientIP(r); got != "198.51.100.5" {
|
|
||||||
t.Fatalf("clientIP = %q, want 198.51.100.5", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
|
|
||||||
// leftmost XFF entry stays limited on their real (rightmost) IP.
|
|
||||||
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
h := srv.routes()
|
h := srv.routes()
|
||||||
for i := 0; i < 5; i++ {
|
codes := []int{}
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
req.RemoteAddr = "10.42.0.7:51000"
|
req.RemoteAddr = "203.0.113.9:51000" // public peer
|
||||||
// each request spoofs a DIFFERENT leftmost entry
|
req.Header.Set("X-Forwarded-For", "9.9.9."+string(rune('0'+i)))
|
||||||
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rr, req)
|
h.ServeHTTP(rr, req)
|
||||||
if rr.Code != 201 {
|
codes = append(codes, rr.Code)
|
||||||
t.Fatalf("req %d: want 201, got %d", i, rr.Code)
|
}
|
||||||
|
allowed := 0
|
||||||
|
for _, c := range codes {
|
||||||
|
if c == 201 {
|
||||||
|
allowed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 6th request, still the same real IP, new spoofed prefix: must 429
|
if allowed > 5 {
|
||||||
|
t.Fatalf("rotating XFF bypassed the limit: allowed=%d codes=%v", allowed, codes)
|
||||||
|
}
|
||||||
|
// and the no-header bucket is the SAME bucket: fully exhausted
|
||||||
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
||||||
req.RemoteAddr = "10.42.0.7:51000"
|
req.RemoteAddr = "203.0.113.9:51000"
|
||||||
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rr, req)
|
h.ServeHTTP(rr, req)
|
||||||
if rr.Code != 429 {
|
if rr.Code != 429 {
|
||||||
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
|
t.Fatalf("no-header request after rotating XFF: want 429, got %d", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func spoofN(i int) string {
|
// clientIP unit checks.
|
||||||
return "1.2.3." + string(rune('0'+i))
|
func TestClientIPIgnoresClientHeadersByDefault(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "198.51.100.5:51000"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
|
||||||
|
r.Header.Set("X-Real-Ip", "9.9.9.9")
|
||||||
|
if got := clientIP(r); got != "198.51.100.5" {
|
||||||
|
t.Fatalf("clientIP = %q, want peer 198.51.100.5", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distinct real IPs must still get distinct buckets (no over-limiting).
|
// Opt-in trusted header: honored only from a trusted private peer and only
|
||||||
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
|
// with a parseable IP value.
|
||||||
srv := newTestServer(t)
|
func TestClientIPTrustedHeaderFromPrivatePeer(t *testing.T) {
|
||||||
h := srv.routes()
|
t.Setenv("PALETTE_CLIENT_IP_HEADER", "Cf-Connecting-Ip")
|
||||||
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
|
// reset the cached CIDR parse (sync.Once) for a clean run
|
||||||
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
|
resetTrustedProxyCache(t)
|
||||||
req.RemoteAddr = "10.42.0.7:51000"
|
|
||||||
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
rr := httptest.NewRecorder()
|
r.RemoteAddr = "10.42.0.7:51000" // in-cluster proxy hop
|
||||||
h.ServeHTTP(rr, req)
|
r.Header.Set("Cf-Connecting-Ip", "203.0.113.42")
|
||||||
if rr.Code != 201 {
|
if got := clientIP(r); got != "203.0.113.42" {
|
||||||
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
|
t.Fatalf("clientIP = %q, want 203.0.113.42", got)
|
||||||
|
}
|
||||||
|
// same header from a PUBLIC peer must be ignored (peer not trusted)
|
||||||
|
r2 := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r2.RemoteAddr = "203.0.113.9:51000"
|
||||||
|
r2.Header.Set("Cf-Connecting-Ip", "6.6.6.6")
|
||||||
|
if got := clientIP(r2); got != "203.0.113.9" {
|
||||||
|
t.Fatalf("clientIP = %q, want peer 203.0.113.9 (untrusted peer)", got)
|
||||||
|
}
|
||||||
|
// garbage header value must be ignored
|
||||||
|
r3 := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r3.RemoteAddr = "10.42.0.7:51000"
|
||||||
|
r3.Header.Set("Cf-Connecting-Ip", "not-an-ip; 6.6.6.6")
|
||||||
|
if got := clientIP(r3); got != "10.42.0.7" {
|
||||||
|
t.Fatalf("clientIP = %q, want peer 10.42.0.7 (bad header value)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom trusted proxy CIDR list is honored.
|
||||||
|
func TestClientIPCustomTrustedProxies(t *testing.T) {
|
||||||
|
t.Setenv("PALETTE_CLIENT_IP_HEADER", "Cf-Connecting-Ip")
|
||||||
|
t.Setenv("PALETTE_TRUSTED_PROXIES", "192.168.50.0/24")
|
||||||
|
resetTrustedProxyCache(t)
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.RemoteAddr = "192.168.50.10:51000"
|
||||||
|
r.Header.Set("Cf-Connecting-Ip", "203.0.113.77")
|
||||||
|
if got := clientIP(r); got != "203.0.113.77" {
|
||||||
|
t.Fatalf("clientIP = %q, want 203.0.113.77", got)
|
||||||
|
}
|
||||||
|
// outside the custom list
|
||||||
|
r2 := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r2.RemoteAddr = "10.42.0.7:51000"
|
||||||
|
r2.Header.Set("Cf-Connecting-Ip", "203.0.113.77")
|
||||||
|
if got := clientIP(r2); got != "10.42.0.7" {
|
||||||
|
t.Fatalf("clientIP = %q, want peer 10.42.0.7 (outside custom trusted list)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 peer without port must not be mangled.
|
||||||
|
func TestHostOnly(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"198.51.100.5:51000": "198.51.100.5",
|
||||||
|
"[2001:db8::1]:8080": "2001:db8::1",
|
||||||
|
"198.51.100.5": "198.51.100.5",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := hostOnly(in); got != want {
|
||||||
|
t.Errorf("hostOnly(%q) = %q, want %q", in, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user