package clientip_test import ( "net/netip" "testing" "sneak.berlin/go/pixa/internal/clientip" ) // Addresses reused across the resolver cases. const ( trustedRangeV4 = "10.0.0.0/8" forwardedV4 = "203.0.113.7" untrustedV4 = "198.51.100.9" trustedPeer = "10.0.0.1:5000" ) // mustPrefixes parses CIDR strings into prefixes for building a resolver. func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix { t.Helper() prefixes := make([]netip.Prefix, 0, len(cidrs)) for _, c := range cidrs { p, err := netip.ParsePrefix(c) if err != nil { t.Fatalf("netip.ParsePrefix(%q) error = %v", c, err) } prefixes = append(prefixes, p) } return prefixes } type resolveCase struct { name string trusted []string remoteAddr string forwardedFor []string want string } // runResolveCases runs each case against a resolver built from its trusted // list and checks the resolved address. func runResolveCases(t *testing.T, cases []resolveCase) { t.Helper() for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { t.Parallel() r := clientip.NewResolver(mustPrefixes(t, tt.trusted...)) got := r.Resolve(tt.remoteAddr, tt.forwardedFor) if got != tt.want { t.Errorf("Resolve(%q, %v) = %q, want %q", tt.remoteAddr, tt.forwardedFor, got, tt.want) } }) } } // TestResolvePeerTrust covers the trust decision on the direct peer: a // forwarded header is believed only from a trusted peer, and a client // connecting directly cannot spoof its address. func TestResolvePeerTrust(t *testing.T) { t.Parallel() runResolveCases(t, []resolveCase{ { name: "trusted peer honors forwarded client", trusted: []string{trustedRangeV4}, remoteAddr: trustedPeer, forwardedFor: []string{forwardedV4}, want: forwardedV4, }, { name: "untrusted peer ignores forwarded header", trusted: []string{trustedRangeV4}, remoteAddr: untrustedV4 + ":33333", forwardedFor: []string{forwardedV4}, want: untrustedV4, }, { name: "spoofed chain from untrusted peer cannot influence result", trusted: []string{trustedRangeV4}, remoteAddr: untrustedV4 + ":33333", forwardedFor: []string{"1.2.3.4, 10.9.9.9, 127.0.0.1"}, want: untrustedV4, }, { name: "empty trusted list always uses peer", trusted: nil, remoteAddr: forwardedV4 + ":80", forwardedFor: []string{"10.0.0.5"}, want: forwardedV4, }, { name: "trusted peer with no forwarded header uses peer", trusted: []string{trustedRangeV4}, remoteAddr: trustedPeer, forwardedFor: nil, want: "10.0.0.1", }, { name: "unparseable peer is returned unchanged", trusted: []string{trustedRangeV4}, remoteAddr: "garbage", forwardedFor: []string{forwardedV4}, want: "garbage", }, }) } // TestResolveChainWalk covers walking the X-Forwarded-For chain from a // trusted peer to the rightmost entry that is not itself a trusted proxy. func TestResolveChainWalk(t *testing.T) { t.Parallel() runResolveCases(t, []resolveCase{ { name: "rightmost untrusted entry across a mixed chain", trusted: []string{trustedRangeV4, "192.168.0.0/16"}, remoteAddr: trustedPeer, forwardedFor: []string{forwardedV4 + ", 192.168.1.1, 10.0.0.2"}, want: forwardedV4, }, { name: "spoofed client behind a trusted proxy is not believed", trusted: []string{trustedRangeV4}, remoteAddr: trustedPeer, forwardedFor: []string{"1.2.3.4, " + untrustedV4}, want: untrustedV4, }, { name: "chain split across multiple header lines", trusted: []string{trustedRangeV4}, remoteAddr: trustedPeer, forwardedFor: []string{forwardedV4, "10.0.0.2"}, want: forwardedV4, }, { name: "garbage entries are skipped", trusted: []string{trustedRangeV4}, remoteAddr: trustedPeer, forwardedFor: []string{forwardedV4 + ", not-an-ip"}, want: forwardedV4, }, { name: "all-trusted chain falls back to peer", trusted: []string{trustedRangeV4}, remoteAddr: trustedPeer, forwardedFor: []string{"10.0.0.9, 10.0.0.2"}, want: "10.0.0.1", }, { name: "trusted IPv6 peer honors forwarded client", trusted: []string{"2001:db8::/32"}, remoteAddr: "[2001:db8::1]:9000", forwardedFor: []string{forwardedV4}, want: forwardedV4, }, { name: "IPv4-mapped peer matches IPv4 trusted range", trusted: []string{trustedRangeV4}, remoteAddr: "[::ffff:10.0.0.1]:5000", forwardedFor: []string{forwardedV4}, want: forwardedV4, }, }) } func TestContextRoundTrip(t *testing.T) { t.Parallel() ctx := clientip.WithClientIP(t.Context(), forwardedV4) if got := clientip.FromContext(ctx); got != forwardedV4 { t.Errorf("FromContext = %q, want %q", got, forwardedV4) } } func TestFromContextAbsent(t *testing.T) { t.Parallel() if got := clientip.FromContext(t.Context()); got != "" { t.Errorf("FromContext with no value = %q, want empty", got) } }