feat(membership): trust reverse-proxy forwarded client IP for rate limit

The per-IP rate limiter keys on the transport RemoteAddr. Behind the
same-origin Caddy proxy that fronts the control plane, every request
arrives with the proxy's single IP, which collapses the limiter into one
bucket shared by the whole world — a flood from one client throttles all
of them.

Add an opt-in `--trusted-proxies` flag (comma-separated IPs/CIDRs). When
the immediate peer is one of the named proxies, clientIP now believes its
X-Forwarded-For (read right-to-left, skipping trusted hops) or X-Real-IP
and keys on the real client. A direct, non-trusted peer's forwarding
headers are ignored entirely, so this opens no quota-fanning hole: an
attacker connecting straight to the public :8470 cannot spoof a key. The
zero value (no flag) preserves the prior RemoteAddr-only behavior exactly.

Covered by ratelimit_proxy_test.go: trusted vs untrusted peers, XFF
right-to-left precedence, client-prepended forgery, X-Real-IP fallback,
and rejection of malformed proxy entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:29:57 +02:00
parent 294905984c
commit 0b96c114b6
4 changed files with 248 additions and 9 deletions
+12 -1
View File
@@ -97,6 +97,17 @@ type Server struct {
// before — so this is opt-in per deployment. Entries are matched exactly (scheme
// + host + port); never use "*" with credentials. Set by the command from a flag.
AllowedOrigins []string
// trustedProxies names the reverse proxies whose forwarding headers
// (X-Forwarded-For / X-Real-IP) the rate limiter is allowed to believe. It
// exists for the same-origin deployment where a single proxy (Caddy) fronts
// the control plane: without it every proxied request would share the proxy's
// one IP and collapse the per-IP rate limit into a single bucket for the whole
// world. Only when the immediate peer is one of these addresses is the
// forwarded client IP trusted; the zero value (nil) trusts nobody, preserving
// the RemoteAddr-only behavior that predates the flag. Set by the command via
// SetTrustedProxies. See clientIP.
trustedProxies trustedProxyMatcher
}
// Posture describes the security posture a membershipd node runs with. It is
@@ -165,7 +176,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
// shed at the cheapest possible point. The health probe is exempt so liveness
// checks are never throttled.
if !isAuthExempt(r) && !s.limiter.allow(clientIP(r), now) {
if !isAuthExempt(r) && !s.limiter.allow(s.clientIP(r), now) {
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}