Files
pad/internal/server/middleware_realip_test.go
T
xarmian fc1f4e6181 fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323) (#1700)
* fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323)

From a trusted peer, TrustedProxyRealIP took X-Real-IP first and
otherwise the LEFTMOST X-Forwarded-For entry. On an appending proxy chain
both are client-controlled: the leftmost entry is whatever the client
sent, and a proxy that only appends to X-Forwarded-For (Azure Application
Gateway, many ingress controllers) passes a client's X-Real-IP through.
Any client behind such a proxy chose the address pad used for per-IP rate
limits, 2FA challenge binding, session IP checks and audit.

Now X-Forwarded-For wins when present, walked right to left, skipping
hops inside PAD_TRUSTED_PROXIES, taking the first address outside them
(nginx real_ip_recursive). ip:port entries are read as their address; an
unparseable entry stops the walk. X-Real-IP is used only when there is no
X-Forwarded-For. Cloud is unaffected: its router appends the resolved
client, which is the right-most hop.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(server): read every X-Forwarded-For field, and document the append requirement (BUG-3323 review)

Codex round 1, P2: Header.Get read only the first X-Forwarded-For field,
so a proxy appending its hop as a second field left the client in control.
All fields are now joined in order before the walk.

The other P2 (a proxy that sets X-Real-IP but passes a client XFF through
unchanged) is the cost of preferring XFF; the deployment guide now states
that every trusted proxy must append to X-Forwarded-For.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 23:19:41 -04:00

286 lines
9.6 KiB
Go

package server
import (
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestParseTrustedProxyCIDRs(t *testing.T) {
tests := []struct {
name string
spec string
wantLen int
contains []string // IPs that should match
rejects []string // IPs that should not match
}{
{
name: "empty spec disables proxy trust",
spec: "",
wantLen: 0,
},
{
name: "single CIDR",
spec: "10.0.0.0/8",
wantLen: 1,
contains: []string{"10.0.0.1", "10.255.255.255"},
rejects: []string{"11.0.0.1", "192.168.1.1"},
},
{
name: "bare IPv4 becomes /32",
spec: "192.168.1.50",
wantLen: 1,
contains: []string{"192.168.1.50"},
rejects: []string{"192.168.1.51"},
},
{
name: "bare IPv6 becomes /128",
spec: "::1",
wantLen: 1,
contains: []string{"::1"},
rejects: []string{"::2"},
},
{
name: "mixed CIDRs and IPs",
spec: "10.0.0.0/8, 172.16.0.0/12, 192.168.1.1",
wantLen: 3,
contains: []string{"10.1.1.1", "172.16.0.5", "192.168.1.1"},
rejects: []string{"8.8.8.8", "192.168.1.2"},
},
{
name: "invalid entries are skipped",
spec: "10.0.0.0/8, not-an-ip, 999.999.999.999",
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseTrustedProxyCIDRs(tt.spec)
if len(got) != tt.wantLen {
t.Fatalf("len(cidrs) = %d, want %d", len(got), tt.wantLen)
}
for _, ip := range tt.contains {
if !ipInCIDRs(net.ParseIP(ip), got) {
t.Errorf("%s should match", ip)
}
}
for _, ip := range tt.rejects {
if ipInCIDRs(net.ParseIP(ip), got) {
t.Errorf("%s should NOT match", ip)
}
}
})
}
}
func TestTrustedProxyRealIP_NoProxies_HeadersIgnored(t *testing.T) {
// When no trusted proxies are configured, proxy headers are completely
// ignored and the real TCP peer address wins.
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
mw := TrustedProxyRealIP(nil)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "203.0.113.5:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.7") // attacker-spoofed
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "203.0.113.5:12345" {
t.Fatalf("RemoteAddr was rewritten despite empty trust list: %s", seen)
}
}
func TestTrustedProxyRealIP_UntrustedPeer_HeadersIgnored(t *testing.T) {
// Proxy headers from an untrusted peer must be ignored even when the
// trust list is non-empty.
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "203.0.113.5:12345" // NOT in 10/8
req.Header.Set("X-Forwarded-For", "198.51.100.7")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "203.0.113.5:12345" {
t.Fatalf("RemoteAddr was rewritten for untrusted peer: %s", seen)
}
}
func TestTrustedProxyRealIP_TrustedPeer_XRealIPUsed(t *testing.T) {
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345" // in trusted CIDR
req.Header.Set("X-Real-IP", "198.51.100.7")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "198.51.100.7" {
t.Fatalf("X-Real-IP from trusted peer was ignored: %s", seen)
}
}
func TestTrustedProxyRealIP_TrustedPeer_XFFTrustedHopSkipped(t *testing.T) {
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.5")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "198.51.100.7" {
t.Fatalf("XFF client behind a trusted hop not honored: %s", seen)
}
}
func TestCapturePeerAddr_PreservesOriginalRemoteAddr(t *testing.T) {
// CapturePeerAddr installs the raw peer into context BEFORE TrustedProxyRealIP
// has a chance to rewrite it — so downstream handlers can always fetch the
// real TCP peer, even on deployments with trusted proxies.
const realPeer = "10.0.0.5:12345"
var seenCtx, seenRemoteAddr string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenCtx = rawPeerAddr(r)
seenRemoteAddr = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
chain := CapturePeerAddr(TrustedProxyRealIP(cidrs)(next))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = realPeer
req.Header.Set("X-Forwarded-For", "198.51.100.7")
chain.ServeHTTP(httptest.NewRecorder(), req)
if seenRemoteAddr != "198.51.100.7" {
t.Fatalf("expected RemoteAddr rewritten to XFF value, got %q", seenRemoteAddr)
}
if seenCtx != realPeer {
t.Fatalf("expected context to preserve raw peer %q, got %q", realPeer, seenCtx)
}
}
func TestRawPeerAddr_FallsBackToRemoteAddrWithoutMiddleware(t *testing.T) {
// In tests (or any call path that skips CapturePeerAddr), rawPeerAddr
// falls back to r.RemoteAddr rather than returning empty.
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.0.2.1:9999"
if got := rawPeerAddr(req); got != "192.0.2.1:9999" {
t.Fatalf("rawPeerAddr fallback = %q, want %q", got, "192.0.2.1:9999")
}
}
func TestTrustedProxyRealIP_TrustedPeer_InvalidHeaderIgnored(t *testing.T) {
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345"
req.Header.Set("X-Real-IP", "not-an-ip")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "10.0.0.5:12345" {
t.Fatalf("invalid header was trusted: %s", seen)
}
}
// BUG-3323: from a trusted peer, the client is the first X-Forwarded-For hop
// outside the trusted CIDRs counted from the RIGHT, and X-Forwarded-For wins
// over X-Real-IP. Every proxy appends the address it received from, so the
// entries left of the first untrusted one, and an X-Real-IP that an
// append-only proxy passed through, are whatever the client sent. The rows
// marked "spoof" are the shapes the leftmost-entry, X-Real-IP-first parse
// resolved to the attacker's chosen address.
func TestTrustedProxyRealIP_BUG3323_ResolvesRightmostUntrustedHop(t *testing.T) {
cases := []struct {
name string
xff, xrip string
want string
}{
{"spoof: client-supplied entry left of the appended client", "6.6.6.6, 198.51.100.7", "", "198.51.100.7"},
{"spoof: client X-Real-IP passed through by an XFF-only proxy", "198.51.100.7", "6.6.6.6", "198.51.100.7"},
{"cloud shape: router overwrote X-Real-IP and appended the client", "6.6.6.6, 198.51.100.7, 198.51.100.7", "198.51.100.7", "198.51.100.7"},
{"several trusted hops are skipped", "198.51.100.7, 10.0.0.9, 10.0.0.5", "", "198.51.100.7"},
{"every hop trusted: the leftmost", "10.0.0.9, 10.0.0.8", "", "10.0.0.9"},
{"spoof: Azure-style ip:port entry", "6.6.6.6, 198.51.100.7:51234", "", "198.51.100.7"},
{"bracketed IPv6 with port", "[2001:db8::1]:443", "", "2001:db8::1"},
{"an unparseable entry stops the walk at the last address read", "198.51.100.7, garbage, 10.0.0.9", "", "10.0.0.9"},
{"only garbage: the peer is kept", "garbage", "", "10.0.0.5:12345"},
{"no XFF: X-Real-IP", "", "198.51.100.7", "198.51.100.7"},
{"blank XFF falls back to X-Real-IP", " ", "198.51.100.7", "198.51.100.7"},
}
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var seen string
mw := TrustedProxyRealIP(cidrs)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
}))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345"
if tc.xff != "" {
req.Header.Set("X-Forwarded-For", tc.xff)
}
if tc.xrip != "" {
req.Header.Set("X-Real-IP", tc.xrip)
}
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != tc.want {
t.Errorf("resolved %q, want %q", seen, tc.want)
}
})
}
}
// A proxy that appends its hop as a SEPARATE X-Forwarded-For field leaves
// the client's own value in the first field, the only one Header.Get reads.
func TestTrustedProxyRealIP_BUG3323_ReadsEveryXFFField(t *testing.T) {
var seen string
mw := TrustedProxyRealIP(ParseTrustedProxyCIDRs("10.0.0.0/8"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
}))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345"
req.Header.Add("X-Forwarded-For", "6.6.6.6")
req.Header.Add("X-Forwarded-For", "198.51.100.7")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "198.51.100.7" {
t.Errorf("resolved %q from two XFF fields, want the appended client 198.51.100.7", seen)
}
}
// The same headers from an UNTRUSTED peer change nothing, walk or no walk.
func TestTrustedProxyRealIP_BUG3323_UntrustedPeerStillIgnored(t *testing.T) {
var seen string
mw := TrustedProxyRealIP(ParseTrustedProxyCIDRs("10.0.0.0/8"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
}))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "203.0.113.9:4000"
req.Header.Set("X-Forwarded-For", "6.6.6.6, 198.51.100.7")
req.Header.Set("X-Real-IP", "6.6.6.6")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "203.0.113.9:4000" {
t.Errorf("an untrusted peer's headers were honoured: %q", seen)
}
}