refactor(xff): split into generic, echo, and fastly packages
Extract generic trusted proxy handling into xff/ (stdlib only), Echo framework adapter into xff/echo/, and slim xff/fastlyxff/ down to Fastly JSON loading. Key changes: - xff/ uses netip.Prefix for efficient IP matching - Fix XFF extraction to walk right-to-left per MDN spec - Remove echo dependency from core xff package - fastlyxff.New() now returns *xff.TrustedProxies
This commit is contained in:
163
xff/xff.go
Normal file
163
xff/xff.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Package xff provides trusted proxy handling and real client IP extraction
|
||||
// from X-Forwarded-For headers.
|
||||
//
|
||||
// This package has no external dependencies — it uses only the Go standard library.
|
||||
//
|
||||
// The XFF extraction algorithm walks right-to-left through the X-Forwarded-For
|
||||
// header, skipping trusted proxy IPs, and returns the first untrusted IP as the
|
||||
// real client address. This follows the MDN-recommended approach for secure
|
||||
// client IP extraction.
|
||||
//
|
||||
// # Usage with net/http middleware
|
||||
//
|
||||
// tp, err := xff.NewFromCIDRs([]string{"10.0.0.0/8", "192.168.0.0/16"})
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// handler := tp.HTTPMiddleware()(yourHandler)
|
||||
//
|
||||
// # Direct extraction
|
||||
//
|
||||
// realIP := tp.ExtractRealIP(r)
|
||||
package xff
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TrustedProxies holds a set of trusted proxy network prefixes and provides
|
||||
// methods for extracting the real client IP from X-Forwarded-For headers.
|
||||
type TrustedProxies struct {
|
||||
prefixes []netip.Prefix
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const realIPKey contextKey = "xff-real-ip"
|
||||
|
||||
// New creates a TrustedProxies from already-parsed prefixes.
|
||||
func New(prefixes ...netip.Prefix) *TrustedProxies {
|
||||
return &TrustedProxies{prefixes: prefixes}
|
||||
}
|
||||
|
||||
// NewFromCIDRs creates a TrustedProxies from CIDR strings (e.g., "10.0.0.0/8").
|
||||
func NewFromCIDRs(cidrs []string) (*TrustedProxies, error) {
|
||||
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
||||
for _, s := range cidrs {
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
return &TrustedProxies{prefixes: prefixes}, nil
|
||||
}
|
||||
|
||||
// AddCIDR adds a CIDR string to the trusted proxy list.
|
||||
func (tp *TrustedProxies) AddCIDR(cidr string) error {
|
||||
p, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tp.prefixes = append(tp.prefixes, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddPrefix adds a parsed prefix to the trusted proxy list.
|
||||
func (tp *TrustedProxies) AddPrefix(prefix netip.Prefix) {
|
||||
tp.prefixes = append(tp.prefixes, prefix)
|
||||
}
|
||||
|
||||
// Prefixes returns a copy of the trusted proxy prefixes.
|
||||
func (tp *TrustedProxies) Prefixes() []netip.Prefix {
|
||||
out := make([]netip.Prefix, len(tp.prefixes))
|
||||
copy(out, tp.prefixes)
|
||||
return out
|
||||
}
|
||||
|
||||
// IsTrusted reports whether ip belongs to any of the trusted proxy ranges.
|
||||
func (tp *TrustedProxies) IsTrusted(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return tp.isTrustedAddr(addr)
|
||||
}
|
||||
|
||||
func (tp *TrustedProxies) isTrustedAddr(addr netip.Addr) bool {
|
||||
for _, p := range tp.prefixes {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractRealIP extracts the real client IP from a request by walking the
|
||||
// X-Forwarded-For header right-to-left, skipping trusted proxy IPs.
|
||||
// If the immediate peer is not a trusted proxy, its IP is returned.
|
||||
func (tp *TrustedProxies) ExtractRealIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
|
||||
hostAddr, err := netip.ParseAddr(host)
|
||||
if err != nil || !tp.isTrustedAddr(hostAddr) {
|
||||
return host
|
||||
}
|
||||
|
||||
xffHeader := r.Header.Get("X-Forwarded-For")
|
||||
if xffHeader == "" {
|
||||
return host
|
||||
}
|
||||
|
||||
ips := strings.Split(xffHeader, ",")
|
||||
|
||||
// Walk right-to-left: skip trusted proxies, return first untrusted IP.
|
||||
for i := len(ips) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(ips[i])
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil || !tp.isTrustedAddr(addr) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
// HTTPMiddleware returns a net/http middleware that extracts the real client IP
|
||||
// from X-Forwarded-For headers and stores it in the request context and
|
||||
// RemoteAddr. The port in RemoteAddr is set to 0 because the original port
|
||||
// belongs to the proxy connection, not the real client.
|
||||
func (tp *TrustedProxies) HTTPMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
realIP := tp.ExtractRealIP(r)
|
||||
ctx := context.WithValue(r.Context(), realIPKey, realIP)
|
||||
r = r.WithContext(ctx)
|
||||
r.RemoteAddr = net.JoinHostPort(realIP, "0")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetRealIP retrieves the real client IP from the request context.
|
||||
// Returns the remote address host if no real IP was extracted by middleware.
|
||||
func GetRealIP(r *http.Request) string {
|
||||
if ip, ok := r.Context().Value(realIPKey).(string); ok {
|
||||
return ip
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user