Skip to content

feat(mcp): rate-limit client_credentials issuance and CIMD fetch attempts (#163)#171

Draft
heskew wants to merge 8 commits into
mainfrom
feat/163-token-rate-limit
Draft

feat(mcp): rate-limit client_credentials issuance and CIMD fetch attempts (#163)#171
heskew wants to merge 8 commits into
mainfrom
feat/163-token-rate-limit

Conversation

@heskew

@heskew heskew commented Jul 11, 2026

Copy link
Copy Markdown
Member

Closes #163 (part 4/4 of #159 — the defense-in-depth rate limiter that gates closing the umbrella). Depends on #170 (merged).

Summary

  • New 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-chosen client_ids / URLs, so the map is capped and evicts LRU), retryAfterSeconds on block.
  • Issuance limiter (token.ts) — at the top of handleClientCredentialsGrant, before resolveClient, so an over-limit client triggers no resolution or crypto work. mcp.clientCredentials.rateLimit requests/min per client_id (default 30; false/0 disables; non-finite → default). Over-limit → 429 { error: "slow_down" } + Retry-After.
  • CIMD fetch limiter (cimd.ts) — fixed 10 attempts/min per client_id URL, 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).
  • Discovery/config/docs unchanged in shape; the grant remains 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 exp bound cross-node abuse.

Where to look

  • rateLimit.ts bucket math — refill, capacity clamp, retry-after, and backward-clock safety (elapsedMs > 0 guard means a backward wall-clock jump only ever makes the limiter stricter). Unit-tested with an injected clock.
  • Limiter ordering in 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:

  1. Per-key limiting doesn't stop an identity-rotating flood. True and intended — per-client_id bucketing bounds per-identity issuance; aggregate work is bounded by the pre-existing MAX_CONCURRENT_RESOLUTIONS=16 / MAX_CONCURRENT_DNS=2 caps + SSRF gate + allowedHosts, not by this limiter. This is a defense-in-depth layer, not the primary control.
  2. LRU eviction can reset a key's bucket. Real mechanism, but it only ever relaxes a limit (an evicted key returns at full capacity), never starves a victim — and reclaiming one key's tokens costs an attacker 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

heskew and others added 2 commits July 11, 2026 11:48
…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
@heskew heskew requested a review from kriszyp July 11, 2026 19:12
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

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:

  • Security-First Limiting: The issuance rate limit is correctly applied after proof-of-possession (signature verification), preventing unauthenticated attackers from draining the quota of legitimate agents.
  • Resource Protection: The fixed-size fingerprinting (SHA-256) of rate-limiter keys and the 2048-character cap on client_id length provide strong defense-in-depth against memory exhaustion from attacker-chosen strings.
  • Continuous Refill Logic: The token-bucket implementation uses elapsed-time refills rather than interval timers, ensuring a smooth and accurate rate limit.
  • Clear Documentation: The updates to configuration.md and mcp-oauth.md accurately reflect the new settings and their technical implications (e.g., per-worker-thread behavior).

All 144 unit tests passed, including the new edge-case tests for bucket capacity, LRU eviction, and Retry-After header generation.

Comment thread src/lib/mcp/token.ts
Comment thread src/lib/mcp/rateLimit.ts

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/lib/mcp/rateLimit.ts
@claude

claude Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

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
Comment thread src/lib/mcp/rateLimit.ts
Comment thread src/lib/mcp/token.ts Outdated

@heskew heskew left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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
@heskew

heskew commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Codex review addressed on 523ce12 (+ ad30fb2):

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 verifyClientAssertion succeeds, keyed by the verified client_id, so only a caller that proved key possession can spend that identity's bucket. Regression test: five forged-assertion requests for the victim's client_id all 401 and leave its quota intact (two valid mints still succeed).

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) + MAX_CONCURRENT_RESOLUTIONS=16 / MAX_CONCURRENT_DNS=2 + the SSRF gate; signature verification is CPU-only (no I/O, no jti burn) and bounded by Node's request concurrency. I did not add the optional transport-identity (IP) pre-auth limiter you floated — generic unauthenticated request flooding is a transport/edge concern, IP-keying misbehaves behind proxies/NAT, and it'd be speculative for v1. Flagging it so you can push back if you'd rather have it.

2. Raw keys don't bound memory (medium) — fixed. createRateLimiter now stores a SHA-256 fingerprint as the map key, so maxKeys bounds bytes (× a constant), not just entry count — for both the grant and CIMD limiters. Combined with the 2048-char client_id cap (ad30fb2) bounding pre-hash input. Added a long-unique-key flood test (4 KB keys, maxKeys: 3) asserting the map stays bounded and LRU eviction still works.

3. Fractional limits permanently deny (low) — already fixed in ad30fb2 (you reviewed the pre-clamp head). createRateLimiter clamps capacity = Math.max(1, rate) while keeping the fractional refill — exactly the second option you gave. Retry-After stays finite because resolveRateLimit treats 0/non-finite as disable/default, so refillPerMinute is always finite-positive when the limiter is active. Fractional-rate test (0.5/min) added.

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

Comment thread docs/mcp-oauth.md Outdated
…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
Comment thread src/lib/mcp/cimd.ts
Comment thread src/lib/mcp/token.ts Outdated
…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
Comment thread src/lib/mcp/rateLimit.ts Outdated
Comment thread src/lib/mcp/authorize.ts
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
Comment thread src/lib/mcp/rateLimit.ts
Comment thread src/lib/mcp/rateLimit.ts
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
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.

client_credentials (4/4): rate-limit token issuance (defense-in-depth)

1 participant