0b96c114b6
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>
197 lines
6.3 KiB
Go
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
|
|
}
|