New internal/clientip package. Resolve honors X-Forwarded-For only when the direct peer falls inside a configured trusted-proxy range; otherwise it returns the peer address and ignores the header, so a client connecting directly cannot spoof its address. When the peer is trusted, the chain is walked right to left and the first entry that is not itself a trusted proxy is returned. Also provides request-context helpers so the resolved address can be shared across middleware and handlers. Tests cover the honored, ignored, and spoofed-chain cases, rightmost selection across a mixed chain, multi-line headers, IPv6 and IPv4-mapped peers, and the context round-trip. Model: opus-4-8
190 lines
5.1 KiB
Go
190 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|