feat(mcp): rate-limit client_credentials issuance and CIMD fetch attempts (#163)#171
feat(mcp): rate-limit client_credentials issuance and CIMD fetch attempts (#163)#171heskew wants to merge 8 commits into
Conversation
…mpts (#163) Closes the last leg of #159 (req 5, defense-in-depth): - New per-node in-memory token-bucket module (rateLimit.ts): continuous refill, injected clock, LRU-bounded key space (keys are attacker-chosen client_ids/URLs). Per-node by design — Harper replication makes a shared counter table a hot-write anti-pattern, and the assertion replay guard + 60s exp window already bound cross-node abuse. - Grant limiter: mcp.clientCredentials.rateLimit requests/min per client_id (default 30; false/0 disables), checked BEFORE resolveClient so an over-limit client triggers no CIMD fetch or crypto work. Over-limit: 429 + error "slow_down" + Retry-After. - CIMD fetch limiter: fixed 10 attempts/min per client_id URL at the resolution layer (post-cache, post-dedup — only actual fetch attempts consume), closing the bad-document fetch-amplification deferral from #167. Both #163-deferral comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
… token; doc the per-thread limit ceiling Cross-model review (domain pass) follow-ups: - Move the MAX_CONCURRENT_RESOLUTIONS check ahead of the per-URL fetch-limiter take so a capacity reject no longer consumes a token without a fetch — makes 'only actual fetch attempts consume' literally true. - Document that the per-node token buckets are in fact per worker thread, so N threads means an N× effective ceiling (inherited from the existing per-thread CIMD cache/concurrency design). Intentional for a defense-in-depth control; docs now say so rather than implying a hard node-wide cap. Both Gemini findings (LRU-reset, per-key 'bypass') were adjudicated by-design/noise and need no change — see PR description. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
|
Reviewed; no blockers found. The PR introduces a robust rate-limiting layer for the MCP authorization flow, addressing both client-credentials issuance and CIMD metadata fetch attempts. The implementation is well-aligned with Harper's per-node/per-worker architectural model, effectively mitigating DoS risks and fetch-amplification attacks without introducing hot-write replication bottlenecks. Key observations:
All 144 unit tests passed, including the new edge-case tests for bucket capacity, LRU eviction, and |
There was a problem hiding this comment.
Code Review
This pull request implements a per-node in-memory token-bucket rate limiter to protect against fetch amplification and restrict token issuance rates. It integrates rate limiting into the client credentials grant flow and CIMD metadata fetches, accompanied by documentation updates and comprehensive unit tests. The review feedback highlights a potential bug where a configured capacity of less than 1 would permanently block all requests, and suggests ensuring the capacity is always at least 1.
|
Reviewed; no blockers found. |
Gemini review follow-ups on #171: - createRateLimiter clamps capacity to >= 1. A configured rate below 1/min gave a burst ceiling under the 1 token a take needs, so the bucket could never admit anyone (worse than the reported 'first request blocked' — it was every request). Refill rate is untouched, so a sub-1/min limit still means one request then one per 60/rate seconds. - Cap client_id at 2048 chars before it becomes a rate-limiter map key (attacker-chosen, retained up to maxKeys). Same defense-in-depth family as the repo's request-path and assertion-length caps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
heskew
left a comment
There was a problem hiding this comment.
Review: request changes
1. Unauthenticated requests can exhaust an agent's issuance quota — medium
src/lib/mcp/token.ts:517-529 debits a bucket keyed only by the submitted client_id, before resolution or assertion verification. CIMD client IDs are public URLs, so any caller can send 30 requests/minute with a known agent URL and a non-empty bogus assertion; the real agent is then rejected with 429 before its valid assertion is evaluated.
Apply the per-client issuance limit only after successful proof-of-possession verification. If pre-auth work also needs a guard, make that a separate limiter keyed by a transport identity (or an aggregate concurrency/cost control), not the claimed client_id. Add a regression test showing malformed assertions cannot drain another client's quota.
2. The LRU entry count does not bound limiter memory — medium
src/lib/mcp/rateLimit.ts:45-73 retains the raw key in its Map; src/lib/mcp/token.ts:492-524 supplies an unvalidated, unauthenticated client_id. maxKeys: 10_000 therefore caps only the number of entries, not their bytes: a flood of long, distinct IDs leaves 10,000 attacker-sized strings reachable. The CIMD limiter has the same raw-key behavior.
Store a fixed-size cryptographic fingerprint as the bucket key and impose a short client-ID length limit before bucket allocation. Add a long, unique-key flood test. This preserves the LRU design while making its memory claim true.
3. Positive fractional limits permanently deny issuance — low
resolveRateLimit() accepts every finite positive number, while getGrantLimiter() uses that number as both capacity and refill rate (src/lib/mcp/token.ts:86-103). rateLimit: 0.5, for example, yields a bucket that can never reach the one token tryTake() requires, and extremely small positive values can produce a non-finite Retry-After.
Require an integer rate of at least one, or use capacity: Math.max(1, rate) while keeping a fractional refill rate. Add fractional-rate tests.
AI-assisted review by Codex (GPT-5).
Codex review (#171 request-changes): - #1 (medium): the issuance limiter debited a bucket keyed by the submitted client_id BEFORE verification. Since CIMD client_ids are public URLs, any caller could flood a known agent's URL with a bogus assertion and 429 the real agent before its valid assertion was checked. Move the limiter to AFTER proof-of-possession, keyed by the verified client_id. Pre-auth work stays bounded by the per-URL CIMD fetch limiter + resolution/DNS concurrency caps (signature verify is CPU-only, no jti burn). Regression test: forged assertions for a victim's client_id can't drain its quota. - #2 (medium): the LRU map retained raw keys, so maxKeys bounded entry COUNT not bytes. Store a SHA-256 fingerprint as the bucket key; memory is now maxKeys x constant regardless of key length. Long-unique-key flood test added. - #3 (low): fractional rates already clamped in ad30fb2 (capacity = max(1, rate), fractional refill preserved). Also folds in two gemini nits: consolidated the redundant buckets.set in tryTake, and the client_id length cap (ad30fb2) bounds pre-hash input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
|
Codex review addressed on 1. Unauthenticated quota exhaustion (medium) — fixed. Real catch — our pre-PR pipeline framed the per-key limiter as benign because it reasoned about an attacker limiting itself, and missed that a public client_id debited pre-auth lets anyone 429 a specific agent. The issuance limiter now runs after Pre-auth work is left bounded by the controls already in place rather than a new limiter: CIMD fetches by the per-URL fetch limiter (10/min) + 2. Raw keys don't bound memory (medium) — fixed. 3. Fractional limits permanently deny (low) — already fixed in Suite: 1036 tests / 1034 pass / 2 pre-existing skips; tsc, lint, prettier clean by exit code. 🤖 Response by Claude (Fable 5) on Nathan's behalf |
…h move The doc still said issuance was limited 'before any client resolution or crypto work' — stale after 523ce12 moved the limiter to after assertion verification. Rewrite to describe the actual (and safer) ordering: debited post-verification so bogus assertions can't drain a real agent's quota, with pre-auth work bounded by the CIMD fetch limiter + concurrency caps. Claude review nit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
…ient_id cap Two gemini nits on #171: - The CIMD fetch rate limit now throws slow_down + statusCode 429 + retryAfterSeconds, and both the token endpoint and the authorize handler emit that status with a Retry-After header (via a shared cimdErrorResponse helper on the token side, widened ErrorJSON on the authorize side) — mirroring the issuance limiter instead of the old 401 temporarily_unavailable that misleadingly read as an auth failure. The concurrency cap now also returns 429 (kept its temporarily_unavailable code — server-busy, not client-too-fast). - MAX_CLIENT_ID_LENGTH moved to cimd.ts and exported; resolveClient now rejects an over-length client_id (unknown-client null) before it becomes a fetch-limiter key, and token.ts imports the shared constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
Two more gemini nits on #171: - Clamp retryAfterSeconds at 2,147,483 (int32-second max) so a tiny configured rate can't advertise a multi-year backoff. - Add an authorize.test.js case that trips the CIMD fetch limiter and asserts handleAuthorize returns 429 slow_down + Retry-After — the 429 status-selection and header branch added in b221570 were only exercised at the token endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
Two gemini nits on #171: - The MAX_RETRY_AFTER_SECONDS comment said ~2.1e9 s; the value is 2,147,483 s (~2.1e6, ≈24.8 days — int32-max ms as whole seconds). - Guard the utility against a non-finite/non-positive refillPerMinute (would make the bucket never refill and divide the retry-after math by zero/negative): fall back to the ≥1 capacity so the limiter stays well-defined even if a future caller misconfigures it. Test added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73
Closes #163 (part 4/4 of #159 — the defense-in-depth rate limiter that gates closing the umbrella). Depends on #170 (merged).
Summary
src/lib/mcp/rateLimit.ts— a minimal per-node in-memory token bucket: continuous elapsed-time refill, injected clock (tests never sleep), LRU-bounded key space (keys are attacker-chosenclient_ids / URLs, so the map is capped and evicts LRU),retryAfterSecondson block.token.ts) — at the top ofhandleClientCredentialsGrant, beforeresolveClient, so an over-limit client triggers no resolution or crypto work.mcp.clientCredentials.rateLimitrequests/min perclient_id(default 30;false/0disables; non-finite → default). Over-limit →429{ error: "slow_down" }+Retry-After.cimd.ts) — fixed 10 attempts/min perclient_idURL, placed after the cache-hit and in-flight-dedup returns so only genuine fetch attempts consume. Closes the two#163-deferred amplification comments from feat: Client ID Metadata Documents with SSRF-guarded resolution + consent interstitial (#166) #167. No config knob (YAGNI — legit clients are cache-served after first success; only failing documents repeat).client_credentials-only.Scope decisions (settled before implementation): grant coverage is client_credentials-only (module is generic, extending to other grants is one line each later); CIMD limit is fixed-policy, no knob; buckets are per-node in-memory — a replicated counter is a Harper hot-write anti-pattern, and the assertion replay guard + ≤60s
expbound cross-node abuse.Where to look
rateLimit.tsbucket math — refill, capacity clamp, retry-after, and backward-clock safety (elapsedMs > 0guard means a backward wall-clock jump only ever makes the limiter stricter). Unit-tested with an injected clock.cimd.ts— the concurrency cap (MAX_CONCURRENT_RESOLUTIONS) is checked before the fetch-limiter take, so a capacity reject doesn't spend a token without fetching.Deliberate design bounds (adjudicated, no change)
Cross-model review (Gemini leg + Harper domain pass; Codex leg stalled — second consecutive PR, no code-trace-depth second model): zero blockers, zero significant concerns. Two Gemini findings were adjudicated and rejected as by-design, surfaced here so the human reviewer needn't rediscover them:
client_idbucketing bounds per-identity issuance; aggregate work is bounded by the pre-existingMAX_CONCURRENT_RESOLUTIONS=16/MAX_CONCURRENT_DNS=2caps + SSRF gate +allowedHosts, not by this limiter. This is a defense-in-depth layer, not the primary control.maxKeys(10k) requests, each doing more work than it saves. Documented as the accepted tradeoff in the module header.Also documented (domain-pass note): the "per node" buckets are in fact per worker thread, so N threads = N× effective ceiling — inherited from the existing per-thread CIMD cache/concurrency design, and consistent with the defense-in-depth framing. Docs now say so rather than implying a hard node-wide cap.
Suite: 1033 tests / 1031 pass / 2 pre-existing skips (+14 new: bucket unit suite, grant 429/disable/no-fetch-on-limit, CIMD per-URL isolation). Generated by an LLM (Claude Fable 5).
🤖 Generated with Claude Code
https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73