From 4f14cd86c3fb86455eb0ac2d425adb44980749a5 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 23:23:47 +0000 Subject: [PATCH] feat: add trusted-proxy-aware client IP resolver 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 --- internal/clientip/clientip.go | 119 ++++++++++++++++++ internal/clientip/clientip_test.go | 189 +++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 internal/clientip/clientip.go create mode 100644 internal/clientip/clientip_test.go diff --git a/internal/clientip/clientip.go b/internal/clientip/clientip.go new file mode 100644 index 0000000..49c2ea7 --- /dev/null +++ b/internal/clientip/clientip.go @@ -0,0 +1,119 @@ +// Package clientip resolves the real client IP address of an HTTP request +// when pixa runs behind a reverse proxy. Forwarding headers are believed +// only when the immediate peer is a configured trusted proxy, so an +// untrusted client cannot spoof its address by sending the header. +package clientip + +import ( + "context" + "net" + "net/netip" + "slices" + "strings" +) + +// ForwardedForHeader is the request header carrying the proxy chain. It is +// honored only when the immediate peer is a trusted proxy. +const ForwardedForHeader = "X-Forwarded-For" + +// Resolver determines the client IP of a request against a fixed set of +// trusted proxy networks. +type Resolver struct { + trusted []netip.Prefix +} + +// NewResolver returns a Resolver that trusts forwarding headers only from +// peers inside the given CIDR ranges. A nil or empty list trusts no one, +// so the peer address is always used. +func NewResolver(trusted []netip.Prefix) *Resolver { + return &Resolver{trusted: trusted} +} + +// Resolve returns the client IP for a request whose direct peer is +// remoteAddr (a "host:port" string as in http.Request.RemoteAddr) and +// whose X-Forwarded-For header lines are forwardedFor (as returned by +// http.Header.Values). When the peer is not a trusted proxy, the peer +// address is returned and the header is ignored entirely. When the peer is +// trusted, the header is walked right to left and the first address that is +// not itself a trusted proxy is returned; this is the client the outermost +// trusted proxy observed, and entries an untrusted client may have prepended +// sit to its left and are never reached. +func (r *Resolver) Resolve(remoteAddr string, forwardedFor []string) string { + peer := hostOnly(remoteAddr) + + peerAddr, err := netip.ParseAddr(peer) + if err != nil || !r.isTrusted(peerAddr) { + return peer + } + + for _, hop := range slices.Backward(forwardedForChain(forwardedFor)) { + hopAddr, err := netip.ParseAddr(hop) + if err != nil || r.isTrusted(hopAddr) { + continue + } + + return hopAddr.String() + } + + return peerAddr.String() +} + +// isTrusted reports whether addr falls inside one of the trusted proxy +// ranges. Addresses are unmapped first so an IPv4-mapped IPv6 form matches +// an IPv4 range, matching the fetcher's blocklist comparison. +func (r *Resolver) isTrusted(addr netip.Addr) bool { + if !addr.IsValid() { + return false + } + + unmapped := addr.Unmap() + + return slices.ContainsFunc(r.trusted, func(prefix netip.Prefix) bool { + return prefix.Contains(unmapped) + }) +} + +// hostOnly strips the port from a "host:port" address. A value without a +// port (already a bare host) is returned unchanged. +func hostOnly(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr + } + + return host +} + +// forwardedForChain flattens the comma-separated entries of every +// X-Forwarded-For header line into a single ordered, trimmed list. +func forwardedForChain(values []string) []string { + var chain []string + + for _, value := range values { + for part := range strings.SplitSeq(value, ",") { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + chain = append(chain, trimmed) + } + } + } + + return chain +} + +// contextKey is the private key type under which the resolved client IP is +// stored in a request context. +type contextKey struct{} + +// WithClientIP returns a copy of ctx carrying the resolved client IP. +func WithClientIP(ctx context.Context, ip string) context.Context { + return context.WithValue(ctx, contextKey{}, ip) +} + +// FromContext returns the resolved client IP stored in ctx, or an empty +// string if none was set. +func FromContext(ctx context.Context) string { + ip, _ := ctx.Value(contextKey{}).(string) + + return ip +} diff --git a/internal/clientip/clientip_test.go b/internal/clientip/clientip_test.go new file mode 100644 index 0000000..ef3a839 --- /dev/null +++ b/internal/clientip/clientip_test.go @@ -0,0 +1,189 @@ +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) + } +}