Files
palette/internal/api/ratelimit_xff_test.go
T
fen a0b34378a4
CI / test (pull_request) Successful in 29s
CI / docker (pull_request) Skipped
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.
2026-09-17 20:34:59 -05:00

136 lines
4.6 KiB
Go

package api
// Issues #85 + #280: the rate limit bucket key must NEVER be derived from a
// client-controlled header. The deployed ingress does not append the peer
// address to X-Forwarded-For, so every XFF entry is attacker-controlled and
// 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 (
"bytes"
"net/http/httptest"
"sync"
"testing"
)
// resetTrustedProxyCache clears the sync.Once-cached CIDR list so each test
// re-parses PALETTE_TRUSTED_PROXIES under its own env.
func resetTrustedProxyCache(t *testing.T) {
t.Helper()
t.Cleanup(func() {
trustedOnce = sync.Once{}
trustedCIDRs = nil
})
trustedOnce = sync.Once{}
trustedCIDRs = nil
}
// Rotating XFF must not create fresh buckets: all requests share the peer's
// bucket. This is the #280 pentest repro (8 creates, 6x201 before the fix).
func TestRateLimitRotatingXFFSharesPeerBucket(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
codes := []int{}
for i := 0; i < 8; i++ {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "203.0.113.9:51000" // public peer
req.Header.Set("X-Forwarded-For", "9.9.9."+string(rune('0'+i)))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
codes = append(codes, rr.Code)
}
allowed := 0
for _, c := range codes {
if c == 201 {
allowed++
}
}
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.RemoteAddr = "203.0.113.9:51000"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("no-header request after rotating XFF: want 429, got %d", rr.Code)
}
}
// clientIP unit checks.
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)
}
}
// Opt-in trusted header: honored only from a trusted private peer and only
// with a parseable IP value.
func TestClientIPTrustedHeaderFromPrivatePeer(t *testing.T) {
t.Setenv("PALETTE_CLIENT_IP_HEADER", "Cf-Connecting-Ip")
// reset the cached CIDR parse (sync.Once) for a clean run
resetTrustedProxyCache(t)
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000" // in-cluster proxy hop
r.Header.Set("Cf-Connecting-Ip", "203.0.113.42")
if got := clientIP(r); got != "203.0.113.42" {
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)
}
}
}