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>
This commit is contained in:
@@ -15,9 +15,36 @@ import (
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
)
|
||||
|
||||
// Body-size ceilings for the control plane. They bound how much an unauthenticated
|
||||
// peer can make the server buffer in RAM before the request is even authenticated
|
||||
// (the signature is verified over the full body, so the body must be read — but
|
||||
// not unboundedly). maxControlBodyBytes covers JSON metadata requests; /blobs gets
|
||||
// a separate, larger ceiling because media ciphertext is legitimately bigger. A
|
||||
// request whose declared Content-Length already exceeds its ceiling is rejected
|
||||
// before a single byte is buffered.
|
||||
const (
|
||||
maxControlBodyBytes = 1 << 20 // 1 MiB for JSON control-plane requests
|
||||
maxBlobBytes = 16 << 20 // 16 MiB for a single media blob upload
|
||||
// MaxHeaderBytes caps request header size; wired into the http.Server by the
|
||||
// command. Exported so the bound lives next to its body-size siblings.
|
||||
MaxHeaderBytes = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
// Per-IP rate-limit defaults for the control plane. Tuned for an interactive
|
||||
// human/agent bus rather than a high-QPS API: a steady ~20 req/s with a burst of
|
||||
// 40 absorbs a chat client's bursty polling while throttling a flood. Loopback
|
||||
// dev stacks pass r<=0 to disable limiting entirely.
|
||||
const (
|
||||
defaultRatePerSec = rate.Limit(20)
|
||||
defaultRateBurst = 40
|
||||
rateBucketTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Server is the HTTP control plane: the authoritative source of room metadata,
|
||||
// membership, and per-epoch sealed keys. The data plane (messages) is NATS.
|
||||
//
|
||||
@@ -32,11 +59,14 @@ type Server struct {
|
||||
mux *http.ServeMux
|
||||
authMode AuthMode
|
||||
nonces *nonceCache
|
||||
limiter *ipRateLimiter
|
||||
}
|
||||
|
||||
// NewServer wires the membership store and blob store into an http.Handler. The
|
||||
// authMode selects the control-plane auth rollout state (AuthOff for callers and
|
||||
// tests that have not migrated to signed requests yet).
|
||||
// tests that have not migrated to signed requests yet). It installs a per-IP
|
||||
// rate limiter with the package defaults; loopback dev behavior is unchanged
|
||||
// because the burst comfortably exceeds any single client's request rate.
|
||||
func NewServer(store *Store, blobs *blobstore.Store, authMode AuthMode) *Server {
|
||||
s := &Server{
|
||||
store: store,
|
||||
@@ -44,6 +74,7 @@ func NewServer(store *Store, blobs *blobstore.Store, authMode AuthMode) *Server
|
||||
mux: http.NewServeMux(),
|
||||
authMode: authMode,
|
||||
nonces: newNonceCache(nonceTTL),
|
||||
limiter: newIPRateLimiter(defaultRatePerSec, defaultRateBurst, rateBucketTTL),
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
@@ -53,23 +84,53 @@ func NewServer(store *Store, blobs *blobstore.Store, authMode AuthMode) *Server
|
||||
// (signature verification + anti-replay + allowlist) ahead of the router
|
||||
// according to authMode, then dispatches to the matched handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
|
||||
// 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) {
|
||||
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
// Cap how much body we will buffer, BEFORE reading a single byte. The ceiling
|
||||
// is per-route: /blobs may legitimately carry a media ciphertext, everything
|
||||
// else is small JSON. A declared Content-Length over the ceiling is rejected
|
||||
// outright (no buffering); MaxBytesReader then guards against a lying or
|
||||
// chunked sender by failing the read once the limit is crossed. This is the
|
||||
// fix for the pre-auth DoS: without it an unauthenticated peer could make the
|
||||
// server buffer an unbounded body in RAM before authenticate() ever ran.
|
||||
limit := int64(maxControlBodyBytes)
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/blobs" {
|
||||
limit = int64(maxBlobBytes)
|
||||
}
|
||||
if r.ContentLength > limit {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
|
||||
if s.authMode == AuthOff || isAuthExempt(r) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Buffer the body so the signature can be verified over it and the handler
|
||||
// still reads it. Bodies on the control plane are small (JSON metadata or a
|
||||
// media blob already capped upstream), so full buffering is acceptable.
|
||||
// Buffer the (now bounded) body so the signature can be verified over it and
|
||||
// the handler still reads it.
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
if isBodyTooLarge(err) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusBadRequest, "read body")
|
||||
return
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
if _, err := s.authenticate(r, body, time.Now()); err != nil {
|
||||
if _, err := s.authenticate(r, body, now); err != nil {
|
||||
if s.authMode == AuthSoft {
|
||||
log.Printf("[auth] soft: would reject %s %s: %v", r.Method, r.URL.Path, err)
|
||||
s.mux.ServeHTTP(w, r)
|
||||
@@ -81,6 +142,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
||||
// when the body exceeds its limit, so the middleware can map it to 413.
|
||||
func isBodyTooLarge(err error) bool {
|
||||
var maxErr *http.MaxBytesError
|
||||
return errors.As(err, &maxErr)
|
||||
}
|
||||
|
||||
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
||||
// Only the unauthenticated health probe qualifies: it carries no data and is
|
||||
// needed by load balancers / smoke checks / systemd before any identity exists.
|
||||
@@ -401,9 +469,18 @@ func (s *Server) handleRekey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request) {
|
||||
// The body arrives already bounded: ServeHTTP wraps it in a MaxBytesReader
|
||||
// (maxBlobBytes) and rejects an over-declared Content-Length before this
|
||||
// handler runs, in every auth mode. Reading here therefore cannot buffer
|
||||
// more than the ceiling; a sender that lies about its length (e.g. chunked)
|
||||
// trips MaxBytesReader and we map that to 413 rather than a generic 400.
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
if isBodyTooLarge(err) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusBadRequest, "read body")
|
||||
return
|
||||
}
|
||||
hash, err := s.blobs.Put(data)
|
||||
|
||||
Reference in New Issue
Block a user