Files
unibus/pkg/membership/ratelimit.go
T
egutierrez 0b96c114b6 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>
2026-06-14 12:29:57 +02:00

197 lines
6.3 KiB
Go

package membership
import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// ipRateLimiter is a per-source-IP token-bucket rate limiter for the control
// plane. It exists to blunt pre-auth flooding: an unauthenticated peer that
// hammers the HTTP API (signature verification is not free, and io is bounded
// but still real) is throttled before it can amplify load. Like the nonceCache,
// this is transport glue specific to unibus, not a registry primitive — the
// report 0003 made the same call for the nonce cache (it would only drag a NATS
// dependency into the multi-domain registry go.mod for one helper).
//
// Each distinct IP gets its own golang.org/x/time/rate.Limiter (a standard
// token bucket already in the module graph, so no new dependency). Idle buckets
// are reaped so the map cannot grow without bound under a churn of source IPs.
type ipRateLimiter struct {
mu sync.Mutex
buckets map[string]*ipBucket
r rate.Limit
burst int
ttl time.Duration
}
type ipBucket struct {
lim *rate.Limiter
seen time.Time
}
// newIPRateLimiter builds a limiter granting r tokens/second with the given
// burst per IP. ttl bounds how long an idle bucket is retained before being
// reaped. r<=0 disables limiting (Allow always true) so dev/loopback stacks are
// unaffected.
func newIPRateLimiter(r rate.Limit, burst int, ttl time.Duration) *ipRateLimiter {
return &ipRateLimiter{
buckets: make(map[string]*ipBucket),
r: r,
burst: burst,
ttl: ttl,
}
}
// allow reports whether a request from ip may proceed now, consuming one token
// on success. A disabled limiter (r<=0) always allows. Reaping of stale buckets
// is amortized: it runs only when the map has grown past a small threshold, so
// the common path is a single map lookup under the mutex.
func (l *ipRateLimiter) allow(ip string, now time.Time) bool {
if l == nil || l.r <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
if len(l.buckets) > 1024 {
l.reapLocked(now)
}
b, ok := l.buckets[ip]
if !ok {
b = &ipBucket{lim: rate.NewLimiter(l.r, l.burst)}
l.buckets[ip] = b
}
b.seen = now
return b.lim.AllowN(now, 1)
}
// reapLocked drops buckets idle for longer than ttl. The caller holds l.mu.
func (l *ipRateLimiter) reapLocked(now time.Time) {
for ip, b := range l.buckets {
if now.Sub(b.seen) > l.ttl {
delete(l.buckets, ip)
}
}
}
// clientIP extracts the rate-limit key for a request: the source IP, with the
// port stripped. By default it trusts the transport's RemoteAddr ONLY (no
// X-Forwarded-For parsing): honoring an attacker-supplied header would let a
// single IP fan its quota across forged identities. When the operator runs the
// control plane behind a reverse proxy they control (the same-origin Caddy
// deployment), SetTrustedProxies names that proxy's address(es); only then, and
// only when the immediate peer is one of them, is the forwarded client IP
// believed. This keeps the per-IP rate limit meaningful behind the proxy, where
// every request would otherwise share the proxy's single IP. If parsing fails the
// whole RemoteAddr is used as the key (still a stable per-connection bucket).
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !s.trustedProxies.has(host) {
return host
}
if fwd := forwardedClientIP(r, s.trustedProxies); fwd != "" {
return fwd
}
return host
}
// forwardedClientIP returns the real client IP a trusted proxy reported, or "" if
// none is present. X-Forwarded-For is read RIGHT-TO-LEFT: the rightmost entry is
// the one our immediate (trusted) proxy appended and therefore cannot be spoofed
// by the client, which can only prepend entries to the left. Trusted-proxy hops
// are skipped so a chain of proxies we own resolves to the first address none of
// them owns — the actual external client. X-Real-IP is a single-value fallback for
// proxies that set it instead. A non-trusted immediate peer never reaches here, so
// a direct attacker's forged header is ignored entirely.
func forwardedClientIP(r *http.Request, trusted trustedProxyMatcher) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip == "" || trusted.has(ip) {
continue
}
if net.ParseIP(ip) != nil {
return ip
}
}
}
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
if net.ParseIP(xrip) != nil {
return xrip
}
}
return ""
}
// trustedProxyMatcher is the set of reverse-proxy addresses whose forwarding
// headers may be honored. The zero value (nil) matches nothing, so the default
// behavior is RemoteAddr-only.
type trustedProxyMatcher []*net.IPNet
// SetTrustedProxies configures the proxies whose X-Forwarded-For / X-Real-IP this
// server trusts for the per-IP rate limit. Each entry is an IP (treated as a /32
// or /128) or a CIDR. It returns an error on the first unparseable entry and
// leaves the previous configuration unchanged. Passing no entries clears the set.
func (s *Server) SetTrustedProxies(entries []string) error {
m, err := parseTrustedProxies(entries)
if err != nil {
return err
}
s.trustedProxies = m
return nil
}
// parseTrustedProxies turns a list of IPs/CIDRs into a matcher. A bare IP becomes
// a host route (/32 for IPv4, /128 for IPv6); blanks are skipped.
func parseTrustedProxies(entries []string) (trustedProxyMatcher, error) {
var m trustedProxyMatcher
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, ipnet, err := net.ParseCIDR(e); err == nil {
m = append(m, ipnet)
continue
}
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("trusted proxy %q is not an IP or CIDR", e)
}
bits := 32
if ip.To4() == nil {
bits = 128
}
m = append(m, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
return m, nil
}
// has reports whether host (an IP string with no port) falls inside any trusted
// range. A nil matcher and an unparseable host both report false.
func (m trustedProxyMatcher) has(host string) bool {
if len(m) == 0 {
return false
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, n := range m {
if n.Contains(ip) {
return true
}
}
return false
}