Resolve real client IP behind trusted proxies (closes #94) #127
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user