Files
pad/internal/server/middleware_realip.go
T
xarmian fc5a54dff7 fix(server): read raw TCP peer for loopback check (TASK-662) (#175)
* fix(server): read raw TCP peer for loopback check (TASK-662)

TrustedProxyRealIP rewrites r.RemoteAddr when the peer is a trusted
proxy. Without additional defense, an attacker reaching a trusted
reverse proxy could set X-Forwarded-For: 127.0.0.1 and trick the
bootstrap loopback check into accepting them as a local caller —
reopening the full-instance-takeover path that TASK-660 closed at the
spoof layer.

Add CapturePeerAddr middleware that runs BEFORE TrustedProxyRealIP and
stashes the untampered r.RemoteAddr in request context. Change
requestIsLoopback to read via rawPeerAddr(r) (context-first, with a
safe fallback for test paths that skip the middleware). r.RemoteAddr
stays the rewritten value for the rate-limiter / audit-log paths that
actually want the client's IP.

Tests cover: direct loopback → true; direct LAN → false; trusted
proxy forwarding spoofed 127.0.0.1 → false; untrusted peer with
spoofed XFF=127.0.0.1 → false; and that rawPeerAddr falls back to
r.RemoteAddr when CapturePeerAddr is absent.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): require loopback peer AND no proxy headers for bootstrap (Codex P1)

Codex caught a regression in the initial PR: reading rawPeerAddr(r) made
every request through a same-host reverse proxy look loopback, so a Caddy
or nginx on 127.0.0.1 forwarding public traffic would let attackers reach
the bootstrap endpoint from the internet.

Tighten the rule to two independent conditions:
 1. The untampered TCP peer is a loopback address.
 2. Neither X-Forwarded-For nor X-Real-IP is set.

A legitimate local CLI calling Pad directly satisfies both. A reverse
proxy forwarding public traffic always sets the forwarding headers, so
the presence of either disqualifies the request. The raw-peer check
still defeats X-Forwarded-For spoofing from non-loopback attackers, and
now also handles the Codex-flagged scenario where a local proxy is
trusted or left misconfigured.

Tests updated to cover: direct loopback no-headers allowed; loopback
peer + XFF rejected; loopback peer + X-Real-IP rejected; IPv6 loopback
allowed.
2026-04-21 19:33:47 -04:00

142 lines
4.4 KiB
Go

package server
import (
"context"
"log/slog"
"net"
"net/http"
"strings"
)
// 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 — accept X-Real-IP or the first
// entry of X-Forwarded-For as the client IP.
var realIP string
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
realIP = v
} else if v := r.Header.Get("X-Forwarded-For"); v != "" {
for _, p := range strings.Split(v, ",") {
p = strings.TrimSpace(p)
if p != "" {
realIP = p
break
}
}
}
if realIP != "" && net.ParseIP(realIP) != nil {
r.RemoteAddr = realIP
}
next.ServeHTTP(w, r)
})
}
}
// 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
}