Skip to content

security: add HTTP rate limit and max-concurrent middleware#31

Draft
rahulcrl wants to merge 1 commit into
mainfrom
add_http_rate_limit
Draft

security: add HTTP rate limit and max-concurrent middleware#31
rahulcrl wants to merge 1 commit into
mainfrom
add_http_rate_limit

Conversation

@rahulcrl

@rahulcrl rahulcrl commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Addresses SECSERV-424 (TM-006). CockroachDB admission control protects the cluster but not this server process. Without per-client throttling, a single misbehaving client can saturate the worker pool and starve other agents sharing the same instance. Pool sizing already shipped in 94fba07; this is the HTTP-mode half.

Adds two HTTP middlewares in the existing auth/ package, both no-op when their knob is zero, so default installs pay nothing.

Middleware chain (outer to inner)

MaxConcurrent (cheap 503 reject under load) -> Bearer (skipped when CRDB_MCP_ALLOW_NO_BEARER=true) -> RateLimit (token-bucket per client key) -> MCP handler.

Client keying (rate limit)

  • Bearer token hash when present: all clients sharing the server token share one bucket, so CRDB_MCP_HTTP_RPS is effectively a global rate in single-token deployments (documented in README).
  • Otherwise RemoteAddr host (IPv6-safe via net.SplitHostPort).
  • X-Forwarded-For is ignored by default because it is client-forgeable. CRDB_MCP_HTTP_TRUST_XFF=true opts in for trusted-proxy deployments and uses the rightmost entry (appended by the nearest proxy), never the client-supplied first entry.

Config

  • CRDB_MCP_HTTP_RPS (float, 0 disables), CRDB_MCP_HTTP_BURST (defaults to 2*RPS; rejected if set while RPS=0), CRDB_MCP_HTTP_MAX_CONCURRENT (0 disables), CRDB_MCP_HTTP_TRUST_XFF (bool, default false).
  • Limiter map capped at 10k keys with oldest-entry eviction to bound memory.

Test plan

  • go test ./... including new t.Run coverage: burst-then-429, independent buckets, XFF ignored-unless-trusted, rightmost-XFF keying, IPv6 RemoteAddr, semaphore cap under concurrency, config validation (negative/non-numeric/non-bool, burst-without-rps)
  • golangci-lint run clean (fixes revive max-builtin and ST1011 failures)
  • Rebased onto main; integrates with security: allow opt-out bearer-token authN in HTTP mode #29 AllowNoBearer (rate limit still applies, keyed by client address)

CockroachDB admission control protects the cluster but not this server
process. Without per-client throttling, a single misbehaving client can
saturate the worker pool and starve other agents on the same instance.
This is the HTTP-mode half of TM-006; pool sizing already shipped in
94fba07.

Add two HTTP middlewares in the existing auth package, both no-op when
their knob is zero:

- RateLimit: per-client token bucket via golang.org/x/time/rate.
  Clients are keyed by bearer-token hash (so a legit caller shares one
  bucket across reconnects), then X-Forwarded-For first hop, then
  RemoteAddr. Per-key map is LRU-capped at 10000 entries so an
  attacker can't grow it without bound. Exceeded buckets get 429 with
  Retry-After: 1.

- MaxConcurrent: semaphore around the chain. Excess in-flight requests
  get 503 server busy (not blocked) so a burst of slow handlers cannot
  exhaust http.Server's goroutine pool.

Chain order in newHTTPMux is outer to inner: MaxConcurrent (cheapest
reject) -> Bearer (cheap reject of bad tokens) -> RateLimit (per-client
throttle on accepted callers) -> mcpHandler. /healthz and /ready bypass
the chain so probes are never throttled.

New env vars (all default 0 = disabled):
- CRDB_MCP_HTTP_RPS
- CRDB_MCP_HTTP_BURST (defaults to 2*RPS when unset)
- CRDB_MCP_HTTP_MAX_CONCURRENT

Addresses SECSERV-424.
@rahulcrl
rahulcrl force-pushed the add_http_rate_limit branch from f7e883f to 5633998 Compare July 3, 2026 13:54
@rahulcrl
rahulcrl marked this pull request as ready for review July 3, 2026 13:58
@rahulcrl
rahulcrl marked this pull request as draft July 3, 2026 13:59
Comment thread auth/ratelimit.go

// clientKey returns a stable per-client identifier, preferring an
// authenticated identity. Bearer tokens are hashed so the key cannot be
// reversed from a memory dump. X-Forwarded-For is used only when trustXFF is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Confidence: 35 (Low)

Rate-limit bypass via Authorization header spoofing when CRDB_MCP_ALLOW_NO_BEARER=true.

clientKey preferentially keys by Authorization: Bearer <token> hash (line 89-91). When bearer enforcement is disabled (AllowNoBearer=true), the Bearer middleware is absent from the chain, so no validation occurs on the Authorization header. An attacker can send a unique Authorization: Bearer <random> value per request, each hashing to a distinct rate-limit key with a fresh burst allowance, effectively bypassing per-client rate limiting entirely.

Mitigated by the documented assumption that AllowNoBearer deployments sit behind a trusted proxy, and by MaxConcurrent as a global backstop. Consider either: (a) only keying by bearer token when the Bearer middleware is active (pass a flag), or (b) documenting that rate limiting is ineffective without bearer enforcement or XFF trust.

Comment thread auth/concurrent_test.go
busy++
}
}
require.Equal(t, 2, ok, "only the cap should succeed while 3 others race the semaphore")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Confidence: 25 (Low)

Potentially flaky test assertion due to goroutine scheduling race.

The test launches 5 goroutines against a limit-2 semaphore, waits for 2 to be in-flight (require.Eventually), then immediately close(hold) to unblock them. The assertion requires exactly ok=2, busy=3, but there's no synchronization ensuring all 5 goroutines have attempted the semaphore before close(hold) frees slots. If a goroutine is delayed by the scheduler past the close(hold), it finds a free slot and succeeds with 200 instead of 503.

Suggested fix: add a barrier (e.g. a sync.WaitGroup or second channel) that each goroutine signals after calling h.ServeHTTP, and wait on it before closing hold. Alternatively, weaken the assertion to require.GreaterOrEqual(t, ok, 2) and require.LessOrEqual(t, busy, 3).

Comment thread auth/ratelimit.go
e = &limiterEntry{limiter: rate.NewLimiter(s.limit, s.burst)}
s.keys[key] = e
}
e.lastSeen = time.Now()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Confidence: 20 (Low)

O(n) eviction scan under the global mutex at map capacity.

evictOldestLocked() linearly scans all maxLimiterKeys (10,000) entries to find the oldest. This runs under s.mu, blocking all concurrent allow() callers. Under sustained load from many unique clients (e.g. a proxy forwarding diverse IPs), the map stays at capacity and every new client triggers the scan, adding lock-contention latency to every in-flight request.

Suggested fix: maintain a min-heap ordered by lastSeen or use container/list as an LRU for O(1) eviction. Alternatively, batch-evict (e.g. drop the oldest 10%) to amortize the cost.

@cockroachlabs-cla-agent

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants