// 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 }