Files
unibus/pkg/membership/ratelimit.go
T
egutierrez 60d6a86655 feat(membership): bound request bodies and add per-IP rate limit
Pre-auth DoS hardening (audit H1, Critical). The control-plane middleware
read the request body with io.ReadAll before authenticating and with no size
cap, so an unauthenticated peer could force the server to buffer an arbitrary
body in RAM (the auditor sent 400 MB and watched RSS climb to ~898 MB).

- ServeHTTP now caps the buffered body before reading: a per-route ceiling
  (1 MiB JSON, 16 MiB /blobs) rejects an over-declared Content-Length outright
  and wraps the body in http.MaxBytesReader so a lying/chunked sender trips at
  the ceiling instead of unbounded.
- handlePutBlob maps the MaxBytesReader cutoff to 413 in every auth mode.
- Per-IP token-bucket rate limiter (golang.org/x/time/rate, already in the
  module graph) sheds floods before auth or body reads. Loopback dev stacks are
  unaffected (burst >> any single client's rate). Kept in-package as transport
  glue, not promoted to the registry, mirroring the nonceCache decision in 0003.
- membershipd sets http.Server.MaxHeaderBytes and ReadHeaderTimeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:16:04 +02:00

94 lines
2.9 KiB
Go

package membership
import (
"net"
"net/http"
"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 source IP of an HTTP request, stripping the port. It
// trusts the transport's RemoteAddr only (no X-Forwarded-For parsing): a public
// deployment terminates TLS at this process or behind a proxy that the operator
// controls, and honoring an attacker-supplied header would let a single IP fan
// its quota across forged identities. If parsing fails the whole RemoteAddr is
// used as the key (still a stable per-connection bucket).
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}