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
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
// 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
|
|
}
|