package httpx import ( "net/http" "net/http/httptest" "net/netip" "testing" ) func prefixes(t *testing.T, cidrs ...string) []netip.Prefix { t.Helper() var out []netip.Prefix for _, c := range cidrs { p, err := netip.ParsePrefix(c) if err != nil { t.Fatal(err) } out = append(out, p) } return out } func TestResolveClient(t *testing.T) { trusted := "172.16.0.0/12,127.0.0.0/8" tests := []struct { name string remoteAddr string cidrs string hdr map[string][]string wantIP string wantProto string wantHost string // empty = request Host }{ { name: "no proxies configured: headers ignored", remoteAddr: "203.0.113.7:4444", cidrs: "", hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}}, wantIP: "203.0.113.7", wantProto: "http", }, { name: "untrusted peer spoofing headers: ignored", remoteAddr: "203.0.113.7:4444", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"evil.example"}}, wantIP: "203.0.113.7", wantProto: "http", }, { name: "trusted peer, single hop", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"bounty.example.com"}}, wantIP: "198.51.100.1", wantProto: "https", wantHost: "bounty.example.com", }, { name: "client-spoofed XFF prefix is skipped (rightmost untrusted wins)", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"6.6.6.6, 198.51.100.1"}}, wantIP: "198.51.100.1", wantProto: "http", }, { name: "chain of trusted proxies collapses to real client", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1, 172.20.0.5, 172.20.0.9"}}, wantIP: "198.51.100.1", wantProto: "http", }, { name: "all-trusted chain falls back to leftmost", remoteAddr: "127.0.0.1:9999", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"172.20.0.5"}}, wantIP: "172.20.0.5", wantProto: "http", }, { name: "trusted peer without forwarded headers", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: nil, wantIP: "172.18.0.2", wantProto: "http", }, { name: "malformed XFF entry stops the walk", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"garbage, 198.51.100.1"}}, wantIP: "198.51.100.1", wantProto: "http", }, { name: "multiple XFF headers are concatenated in order", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1", "172.20.0.5"}}, wantIP: "198.51.100.1", wantProto: "http", }, { name: "invalid forwarded proto ignored", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"gopher"}}, wantIP: "198.51.100.1", wantProto: "http", }, { name: "ipv6 client through trusted proxy", remoteAddr: "172.18.0.2:1234", cidrs: trusted, hdr: map[string][]string{"X-Forwarded-For": {"2001:db8::1"}}, wantIP: "2001:db8::1", wantProto: "http", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "http://app.local/", nil) r.RemoteAddr = tt.remoteAddr for k, vs := range tt.hdr { for _, v := range vs { r.Header.Add(k, v) } } var cidrs []netip.Prefix if tt.cidrs != "" { cidrs = prefixes(t, "172.16.0.0/12", "127.0.0.0/8") } got := resolveClient(r, cidrs, "http") if got.IP.String() != tt.wantIP { t.Errorf("IP = %s, want %s", got.IP, tt.wantIP) } if got.Proto != tt.wantProto { t.Errorf("Proto = %s, want %s", got.Proto, tt.wantProto) } wantHost := tt.wantHost if wantHost == "" { wantHost = "app.local" } if got.Host != wantHost { t.Errorf("Host = %s, want %s", got.Host, wantHost) } }) } }