Files
upstream-chi/middleware/client_ip.go
upstream-mirror 72c9f2ee75
Some checks failed
Test / test (1.24.x, ubuntu-latest) (push) Successful in 2m56s
Test / test (1.25.x, ubuntu-latest) (push) Successful in 2m15s
Test / test (1.26.x, ubuntu-latest) (push) Successful in 2m23s
Test / test (1.27.x, ubuntu-latest) (push) Failing after 3m56s
Test / test (1.24.x, windows-latest) (push) Has been cancelled
Test / test (1.25.x, windows-latest) (push) Has been cancelled
Test / test (1.26.x, windows-latest) (push) Has been cancelled
Test / test (1.27.x, windows-latest) (push) Has been cancelled
snapshot of https://github.com/go-chi/chi.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:48:17 +05:00

275 lines
10 KiB
Go

package middleware
import (
"context"
"net"
"net/http"
"net/netip"
"slices"
"strings"
)
// clientIPCtxKey stores the client IP set by any of the ClientIPFrom* middlewares.
var clientIPCtxKey = &contextKey{"clientIP"}
// xForwardedForHeader is the canonical form of the X-Forwarded-For header
// name, used by the XFF-based middlewares.
const xForwardedForHeader = "X-Forwarded-For"
// ClientIPFromHeader stores the client IP from a single-IP header set by
// your reverse proxy. Read it with [GetClientIP].
//
// Only safe with headers your proxy unconditionally OVERWRITES on every
// request, e.g.:
//
// - X-Real-IP — Nginx with ngx_http_realip_module
// - X-Client-IP — Apache with mod_remoteip
// - CF-Connecting-IP — Cloudflare
//
// True-Client-IP, X-Azure-ClientIP, and Fastly-Client-IP look similar but
// pass through from the client by default in those products; don't use them
// unless your edge strips the inbound value.
//
// If the header reaches us with multiple values (misconfigured proxy that
// appends, or a downstream proxy not stripping a client-supplied value),
// the LAST value wins — that's the one set by the hop closest to us, and
// therefore the most trusted. Fail-closed if the last value doesn't parse:
// no client IP is set rather than falling back to earlier (less-trusted)
// values.
//
// v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are
// stripped before storage.
func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler {
header := http.CanonicalHeaderKey(trustedHeader)
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
values := r.Header.Values(header)
if len(values) > 0 {
if ip, ok := parseHeaderAddr(values[len(values)-1]); ok {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
}
}
h.ServeHTTP(w, r)
})
}
}
// ClientIPFromXFF stores the client IP read from the X-Forwarded-For header,
// walking the chain right-to-left and skipping any IP that falls within one
// of the given trusted CIDR prefixes. The first IP that is not trusted is
// the client. Read it with [GetClientIP].
//
// An unparseable entry mid-chain aborts the walk and leaves no client IP
// set (fail-closed) — we can't safely trust anything left of garbage.
//
// Use this when you sit behind one or more reverse proxies whose IP ranges
// you can enumerate as CIDRs. Most CDNs publish their IPs:
//
// Cloudflare: https://www.cloudflare.com/ips/
// AWS: https://ip-ranges.amazonaws.com/ip-ranges.json
// Fastly: https://api.fastly.com/public-ip-list
// Google Cloud: https://www.gstatic.com/ipranges/cloud.json
//
// Example (CloudFront):
//
// r.Use(middleware.ClientIPFromXFF(
// "13.32.0.0/15", // CloudFront IPv4
// "52.46.0.0/18", // CloudFront IPv4
// "2600:9000::/28", // CloudFront IPv6
// ))
//
// Calling with no arguments returns the rightmost XFF entry, or no IP if
// that entry doesn't parse (fail-closed) — safe only if you have exactly
// one trusted hop directly in front of this server (e.g., nginx on localhost).
//
// v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are
// stripped before the prefix check and storage; otherwise an attacker
// could use either notation to alias a trusted IP past the check.
//
// If you know the number of trusted proxies but not their IPs, use
// [ClientIPFromXFFTrustedProxies] instead.
//
// Panics at startup if any prefix is invalid.
func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handler {
prefixes := make([]netip.Prefix, len(trustedIPPrefixes))
for i, p := range trustedIPPrefixes {
prefixes[i] = netip.MustParsePrefix(p)
}
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var found netip.Addr
walkXFF(r.Header[xForwardedForHeader], func(v string) bool {
ip, ok := parseHeaderAddr(v)
if !ok {
return true // fail-closed; leave found unset
}
if inAnyPrefix(ip, prefixes) {
return false // trusted hop; keep walking left
}
found = ip
return true
})
if found.IsValid() {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, found))
}
h.ServeHTTP(w, r)
})
}
}
// ClientIPFromXFFTrustedProxies stores the client IP read from
// X-Forwarded-For, given the exact number of trusted reverse proxies
// between this server and the public internet. Read it with [GetClientIP].
//
// PREFER [ClientIPFromXFF] with explicit CIDRs whenever you can — it
// cannot off-by-one and is robust to architecture changes. Most CDNs
// publish their IP ranges (Cloudflare, AWS, Fastly, Google Cloud). Use
// this counting variant only when proxy IPs are dynamic and unpublishable.
//
// numTrustedProxies = total proxy hops between the client and this server.
// Count every hop in the request path:
//
// Single proxy (one LB / nginx / Heroku / Fly.io / Render) ....... 1
// Two proxies (Cloudflare → ALB, CloudFront → ALB) .............. 2
// Three proxies (CDN → API gateway → LB) ......................... 3
//
// VERIFY BEFORE GOING LIVE: send a request from a known IP and confirm
// [GetClientIP] returns that IP. If it returns a proxy IP, your count is
// too LOW — a client can spoof their IP, fix immediately. If it returns
// "", your count is too HIGH — no leak, but no client IP either.
//
// This middleware reads ONLY X-Forwarded-For; it does not inspect
// r.RemoteAddr. Guarantee at the network layer (security group / firewall)
// that only your proxies can reach this server.
//
// If the XFF chain has fewer than numTrustedProxies entries, no client IP
// is set (fail-closed). Like [ClientIPFromXFF], v4-mapped IPv6 folds to v4
// and IPv6 zones are stripped before storage.
//
// Panics at startup if numTrustedProxies < 1.
func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler {
if numTrustedProxies < 1 {
panic("middleware.ClientIPFromXFFTrustedProxies: numTrustedProxies must be >= 1")
}
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := numTrustedProxies
var entry string
walkXFF(r.Header[xForwardedForHeader], func(v string) bool {
n--
if n == 0 {
entry = v
return true
}
return false
})
if entry != "" {
if ip, ok := parseHeaderAddr(entry); ok {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
}
}
h.ServeHTTP(w, r)
})
}
}
// ClientIPFromRemoteAddr stores the client IP read from the TCP RemoteAddr
// of the incoming request — the IP address of whoever opened the connection
// to this server. Read it with [GetClientIP].
//
// Use this when this server is directly connected to the public internet
// with NO reverse proxy in front of it. Behind a reverse proxy, RemoteAddr
// is the proxy's IP, not the client's — use [ClientIPFromHeader] or
// [ClientIPFromXFF] instead.
//
// IPv4 clients on a dual-stack listener surface as ::ffff:a.b.c.d; they
// fold to plain v4 before storage so one logical client maps to one key.
// IPv6 zones are preserved (link-local connections may legitimately have one).
func ClientIPFromRemoteAddr(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr // RemoteAddr may already be a bare IP (e.g. in tests).
}
if ip, err := netip.ParseAddr(host); err == nil {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip.Unmap()))
}
h.ServeHTTP(w, r)
})
}
// GetClientIP returns the client IP as a string, as set by one of the
// ClientIPFrom* middlewares. Returns "" if no valid IP was set.
// Convenient for logging, rate-limit keys, etc.
func GetClientIP(ctx context.Context) string {
ip := GetClientIPAddr(ctx)
if !ip.IsValid() {
return ""
}
return ip.String()
}
// GetClientIPAddr returns the client IP as a [netip.Addr], as set by one of
// the ClientIPFrom* middlewares. The returned Addr is the zero value if not
// set; use [netip.Addr.IsValid] to check. Useful when you need typed work —
// prefix containment, Is4/Is6, etc. — without re-parsing the string.
func GetClientIPAddr(ctx context.Context) netip.Addr {
ip, _ := ctx.Value(clientIPCtxKey).(netip.Addr)
return ip
}
// walkXFF walks the entries of the merged X-Forwarded-For chain
// RIGHT-TO-LEFT, invoking visit on each trimmed non-empty entry. visit
// returns true to stop the walk. Lazy walk, zero allocations (entries
// are substrings of the input headers).
//
// Multiple XFF headers are merged per RFC 2616 — each header's
// comma-separated entries in order received — so an attacker cannot pick
// which value security logic sees by sending a duplicate header.
func walkXFF(headers []string, visit func(entry string) bool) {
for _, h := range slices.Backward(headers) {
for h != "" {
var v string
if i := strings.LastIndexByte(h, ','); i >= 0 {
v, h = h[i+1:], h[:i]
} else {
v, h = h, ""
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
if visit(v) {
return
}
}
}
}
// inAnyPrefix reports whether ip falls within any of the given prefixes.
func inAnyPrefix(ip netip.Addr, prefixes []netip.Prefix) bool {
for _, p := range prefixes {
if p.Contains(ip) {
return true
}
}
return false
}
// parseHeaderAddr parses s and normalizes for storage: v4-mapped IPv6
// (::ffff:a.b.c.d) folds to plain v4, IPv6 zone is stripped. Both defend the
// trust-prefix check against attacker-injected aliases — [netip.Prefix.Contains]
// returns false for v4-mapped addresses vs v4 prefixes and for any zoned
// address, so without folding/stripping an attacker could escape an
// otherwise valid trust list.
//
// Header-sourced IPs only. [ClientIPFromRemoteAddr] normalizes inline
// (Unmap, but zone preserved for legitimate link-local connections).
func parseHeaderAddr(s string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return ip.Unmap().WithZone(""), true
}