Files
pad/internal/server/middleware_realip.go
T
xarmian fc1f4e6181 fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323) (#1700)
* fix(server): resolve the client from the right-most untrusted X-Forwarded-For hop (BUG-3323)

From a trusted peer, TrustedProxyRealIP took X-Real-IP first and
otherwise the LEFTMOST X-Forwarded-For entry. On an appending proxy chain
both are client-controlled: the leftmost entry is whatever the client
sent, and a proxy that only appends to X-Forwarded-For (Azure Application
Gateway, many ingress controllers) passes a client's X-Real-IP through.
Any client behind such a proxy chose the address pad used for per-IP rate
limits, 2FA challenge binding, session IP checks and audit.

Now X-Forwarded-For wins when present, walked right to left, skipping
hops inside PAD_TRUSTED_PROXIES, taking the first address outside them
(nginx real_ip_recursive). ip:port entries are read as their address; an
unparseable entry stops the walk. X-Real-IP is used only when there is no
X-Forwarded-For. Cloud is unaffected: its router appends the resolved
client, which is the right-most hop.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(server): read every X-Forwarded-For field, and document the append requirement (BUG-3323 review)

Codex round 1, P2: Header.Get read only the first X-Forwarded-For field,
so a proxy appending its hop as a second field left the client in control.
All fields are now joined in order before the walk.

The other P2 (a proxy that sets X-Real-IP but passes a client XFF through
unchanged) is the cost of preferring XFF; the deployment guide now states
that every trusted proxy must append to X-Forwarded-For.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 23:19:41 -04:00

202 lines
7.3 KiB
Go

package server
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"sync"
)
// peerAddrCtxKey carries the untampered TCP peer address (the original
// r.RemoteAddr) through the request context. Middleware downstream of
// TrustedProxyRealIP can't read the raw address off r.RemoteAddr anymore
// because the rewrite is already baked in.
type peerAddrCtxKey struct{}
// CapturePeerAddr must be installed BEFORE TrustedProxyRealIP in the chain.
// It snapshots the real TCP peer RemoteAddr into the request context so
// authenticity checks (e.g. bootstrap loopback) can verify the actual wire
// peer even when a trusted proxy has rewritten r.RemoteAddr.
func CapturePeerAddr(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), peerAddrCtxKey{}, r.RemoteAddr)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// rawPeerAddr returns the untampered TCP peer address captured by
// CapturePeerAddr, falling back to the current r.RemoteAddr if the
// middleware wasn't installed (e.g. in tests). Never use r.RemoteAddr
// directly for authenticity decisions — use this.
func rawPeerAddr(r *http.Request) string {
if v, ok := r.Context().Value(peerAddrCtxKey{}).(string); ok && v != "" {
return v
}
return r.RemoteAddr
}
// TrustedProxyRealIP returns middleware that rewrites r.RemoteAddr from
// X-Real-IP / X-Forwarded-For ONLY when the direct TCP peer is within one
// of the supplied trusted-proxy CIDRs. This replaces chimiddleware.RealIP
// which trusts proxy headers unconditionally — that is unsafe for any
// deployment directly exposed to untrusted networks, because any client
// can spoof X-Forwarded-For to bypass IP rate limits, the bootstrap
// loopback check, and IP-based audit logs.
//
// Pass cidrs == nil (the default when PAD_TRUSTED_PROXIES is unset) to
// disable proxy-header trust entirely; the TCP peer address is then used
// everywhere, which is the safe behavior for direct-exposed servers.
func TrustedProxyRealIP(cidrs []*net.IPNet) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if len(cidrs) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
peerIP := peerAddr(r.RemoteAddr)
if peerIP == nil || !ipInCIDRs(peerIP, cidrs) {
next.ServeHTTP(w, r)
return
}
// Peer is a trusted proxy. X-Forwarded-For wins when present,
// resolved by forwardedClientIP; X-Real-IP is read only when
// there is no X-Forwarded-For (BUG-3323). The order matters: a
// proxy that only APPENDS to X-Forwarded-For passes a client's
// own X-Real-IP through untouched, so preferring X-Real-IP, or
// taking the leftmost X-Forwarded-For entry as this used to,
// let any client behind such a proxy choose its address.
var realIP string
// Every X-Forwarded-For field, in order: a proxy may append its
// hop as a SECOND field rather than to the client's, and
// Header.Get would read only the client's (BUG-3323 review).
if v := strings.Join(r.Header.Values("X-Forwarded-For"), ","); strings.TrimSpace(v) != "" {
realIP = forwardedClientIP(v, cidrs)
} else if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
realIP = v
}
if realIP != "" && net.ParseIP(realIP) != nil {
r.RemoteAddr = realIP
}
// PLAN-2310 DR-9: every per-client limit keys on the address
// resolved here. If a proxied request resolves to an address
// that is itself inside the trusted range (the proxy forwards
// no client address, or forwards its own), every client shares
// one bucket and the limits throttle the whole deployment. Say
// so once, loudly, on the first such request.
if resolved := peerAddr(r.RemoteAddr); resolved != nil && ipInCIDRs(resolved, cidrs) {
warnResolvedInTrustedRange.Do(func() {
slog.Warn("rate limits: a proxied request resolved to an address inside PAD_TRUSTED_PROXIES, so per-client limits would key on the proxy and throttle every client together; make the proxy send X-Forwarded-For or X-Real-IP with the client address",
"resolved", resolved.String())
})
}
next.ServeHTTP(w, r)
})
}
}
// warnResolvedInTrustedRange makes TrustedProxyRealIP's misconfiguration
// warning fire once per process.
var warnResolvedInTrustedRange sync.Once
// ParseTrustedProxyCIDRs parses a comma-separated list of CIDRs or bare
// IPs from the PAD_TRUSTED_PROXIES setting. Bare IPs get /32 (IPv4) or
// /128 (IPv6). Invalid entries are logged and skipped so an operator typo
// can't crash startup — but if the result is empty, proxy headers remain
// untrusted.
func ParseTrustedProxyCIDRs(spec string) []*net.IPNet {
if spec == "" {
return nil
}
var out []*net.IPNet
for _, raw := range strings.Split(spec, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
// Accept bare IPs by appending /32 or /128.
if !strings.Contains(raw, "/") {
ip := net.ParseIP(raw)
if ip == nil {
slog.Warn("PAD_TRUSTED_PROXIES: skipping invalid entry", "entry", raw)
continue
}
if ip.To4() != nil {
raw += "/32"
} else {
raw += "/128"
}
}
_, cidr, err := net.ParseCIDR(raw)
if err != nil {
slog.Warn("PAD_TRUSTED_PROXIES: skipping invalid CIDR", "entry", raw, "error", err)
continue
}
out = append(out, cidr)
}
return out
}
// peerAddr extracts the IP from a RemoteAddr string, which may be either
// "host:port" (the stdlib default) or a bare "host" (what some middleware
// leaves behind after its own rewriting). Returns nil if parsing fails.
func peerAddr(remoteAddr string) net.IP {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
return net.ParseIP(host)
}
func ipInCIDRs(ip net.IP, cidrs []*net.IPNet) bool {
for _, c := range cidrs {
if c.Contains(ip) {
return true
}
}
return false
}
// forwardedClientIP resolves the client address from an X-Forwarded-For
// chain the way nginx's real_ip_recursive does (BUG-3323): walk it right to
// left, skipping hops inside the trusted CIDRs, and take the first address
// outside them. Every proxy APPENDS the address it received the request
// from, so only the entries to the right of the first untrusted one were
// written by proxies we trust; anything to its left came from the client.
// When every entry is trusted, the leftmost is the best answer, and the
// caller's in-trusted-range warning reports it. An entry that does not parse
// as an IP stops the walk: nothing to its left can be vouched for, so the
// last address that did parse is returned, and "" when none did.
func forwardedClientIP(xff string, cidrs []*net.IPNet) string {
parts := strings.Split(xff, ",")
last := ""
for i := len(parts) - 1; i >= 0; i-- {
ip := parseForwardedIP(parts[i])
if ip == nil {
return last
}
last = ip.String()
if !ipInCIDRs(ip, cidrs) {
return last
}
}
return last
}
// parseForwardedIP reads one X-Forwarded-For entry. Some proxies append the
// peer's port, as "203.0.113.7:51234" or "[2001:db8::1]:51234" (Azure
// Application Gateway does), so an entry that is not a bare address is
// retried as host:port.
func parseForwardedIP(entry string) net.IP {
entry = strings.TrimSpace(entry)
if ip := net.ParseIP(entry); ip != nil {
return ip
}
if host, _, err := net.SplitHostPort(entry); err == nil {
return net.ParseIP(host)
}
return nil
}