package auth import ( "net/http" "testing" ) // The hop arithmetic is the whole feature, so the table is written as // deployment topologies rather than abstract inputs. func TestClientIP(t *testing.T) { tests := []struct { name string hops int remoteAddr string forwarded string realIP string want string }{ { name: "no proxy configured, socket peer wins", hops: 0, remoteAddr: "203.0.113.5:51234", want: "203.0.113.5", }, { // hops 0 is the setting for a directly-exposed instance, and it // must make forged headers inert. name: "hops 0 ignores a forged forwarded header", hops: 0, remoteAddr: "203.0.113.5:51234", forwarded: "198.51.100.99", want: "203.0.113.5", }, { // The common case: one TLS-terminating proxy. Note RemoteAddr is // PUBLIC here — a proxy on its own host — which the previous // private-range heuristic got wrong. name: "one proxy on a public address yields the client", hops: 1, remoteAddr: "203.0.113.200:40000", forwarded: "198.51.100.7", want: "198.51.100.7", }, { name: "one proxy on a private address yields the client", hops: 1, remoteAddr: "172.18.0.1:40000", forwarded: "198.51.100.7", want: "198.51.100.7", }, { // client -> Cloudflare -> own proxy -> app. // Trusting only our own proxy, the honest answer is Cloudflare: // that's the address our proxy actually observed. name: "cdn chain with hops 1 stops at the cdn", hops: 1, remoteAddr: "172.18.0.1:40000", forwarded: "198.51.100.7, 203.0.113.50", want: "203.0.113.50", }, { // Same chain, both hops trusted — now we reach the real client. name: "cdn chain with hops 2 reaches the client", hops: 2, remoteAddr: "172.18.0.1:40000", forwarded: "198.51.100.7, 203.0.113.50", want: "198.51.100.7", }, { // A client prepending a lie is only reachable if the operator // over-counts their proxies; at the correct depth it's skipped. name: "forged prefix is not reached at the correct depth", hops: 1, remoteAddr: "172.18.0.1:40000", forwarded: "1.2.3.4, 198.51.100.7", want: "198.51.100.7", }, { // The documented mis-set failure, pinned so it stays a KNOWN // consequence rather than a surprise: depth deeper than the real // chain reads attacker-supplied input. name: "hops set deeper than the chain clamps to the leftmost entry", hops: 5, remoteAddr: "172.18.0.1:40000", forwarded: "1.2.3.4, 198.51.100.7", want: "1.2.3.4", }, { name: "no forwarding header falls back to the socket peer", hops: 1, remoteAddr: "203.0.113.5:51234", want: "203.0.113.5", }, { name: "x-real-ip used when forwarded-for is absent", hops: 1, remoteAddr: "172.18.0.1:40000", realIP: "198.51.100.7", want: "198.51.100.7", }, { name: "forwarded-for wins over x-real-ip when both present", hops: 1, remoteAddr: "172.18.0.1:40000", forwarded: "198.51.100.7", realIP: "1.2.3.4", want: "198.51.100.7", }, { // Positions are preserved, so a garbage hop can be selected — // in which case we fall back rather than return nonsense. name: "unparseable selected entry falls back to the socket peer", hops: 1, remoteAddr: "172.18.0.1:40000", forwarded: "198.51.100.7, not-an-ip", want: "172.18.0.1", }, { name: "ipv6 client through one proxy", hops: 1, remoteAddr: "[fd00::1]:40000", forwarded: "2001:db8::5", want: "2001:db8::5", }, { name: "ipv6 socket peer without proxy", hops: 0, remoteAddr: "[2001:db8::1]:51234", want: "2001:db8::1", }, { name: "remote addr without a port is tolerated", hops: 0, remoteAddr: "203.0.113.5", want: "203.0.113.5", }, { name: "empty remote addr yields empty", hops: 1, remoteAddr: "", want: "", }, { name: "whitespace-only forwarded header is treated as absent", hops: 1, remoteAddr: "172.18.0.1:40000", forwarded: " ", want: "172.18.0.1", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { r, err := http.NewRequest(http.MethodGet, "/api/me/sessions", nil) if err != nil { t.Fatalf("NewRequest: %v", err) } r.RemoteAddr = tc.remoteAddr if tc.forwarded != "" { r.Header.Set("X-Forwarded-For", tc.forwarded) } if tc.realIP != "" { r.Header.Set("X-Real-IP", tc.realIP) } if got := ClientIP(r, tc.hops); got != tc.want { t.Errorf("ClientIP(hops=%d) = %q, want %q", tc.hops, got, tc.want) } }) } }