Rate limiter: IP allowlist + descriptive rejection payloads#42
Open
Rate limiter: IP allowlist + descriptive rejection payloads#42
Conversation
Operators can now add trusted IPs or CIDR ranges to server.allowlist in pathfinder.yaml — allowlisted traffic bypasses max_sessions_per_ip entirely. Intended for trusted crawlers (the Anthropic Assistant crawler at 160.79.106.35 is our motivating case) and internal health probes. - New ServerConfigSchema.server.allowlist: array of IPv4/IPv6 addresses or CIDR ranges, validated via ipaddr.js (no hand-rolled CIDR parsing). Empty by default; no IPs ship allowlisted out of the box. - IpSessionLimiter gains an allowlist option + isAllowlisted() check. Allowlisted traffic is invisible to the counter: tryAdd() returns true without incrementing, and remove() is a no-op for those sids. - IPv4-mapped IPv6 (::ffff:127.0.0.1) normalizes to IPv4 so dual-stack listeners still match 127.0.0.0/8-style allowlist entries. - Bypass log is info-level and throttled to once per IP per 5 minutes so a high-volume allowlisted crawler doesn't flood the logs. - Docs + pathfinder.example.yaml call out the Anthropic IP as a commented example (still empty by default).
Rate-limited clients previously got either a bare
'{error: "Too many sessions from this IP"}' on /sse or — worse — a
silent transport.close() with no explanation on /mcp. Neither told the
client the configured cap, their current usage, when to retry, or how
to reach us about getting allowlisted.
- New shared rate-limit-response module. buildRateLimitPayload()
returns {error:'rate_limited', reason, limit, currentCount,
retryAfterSeconds, contact:'[email protected]'};
jsonRpcRateLimitError() wraps that payload in a JSON-RPC 2.0 error
frame at code -32005 (server-error range), with the init request id
echoed back so the client can correlate.
- /sse path: replaces the bare string body with the structured
payload + a Retry-After header. The retry value comes from the
configured session TTL so it actually corresponds to when a slot
will free up.
- /mcp path: pre-checks the IP limiter before creating the transport
and responds with a 429 + Retry-After + JSON-RPC error frame instead
of the silent-close path. A defensive post-init fallback remains for
the concurrent-init race, but the common case is now observable
from the client.
34d8dbc to
d774bc6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
server.allowlist— a list of IPs/CIDRs that bypassmax_sessions_per_ip. Motivated by the Anthropic Assistant crawler (160.79.106.35) currently getting throttled. Empty by default; operators opt in per deployment.{error: "Too many sessions from this IP"}429 on/sseand the silenttransport.close()on/mcpinit with a structured payload clients can actually react to:{error, reason, limit, currentCount, retryAfterSeconds, contact}./mcpadditionally sends a JSON-RPC 2.0 error frame at code-32005.ipaddr.js(already in the transitive tree, now a direct dep). IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) normalize to IPv4 so loopback/internal CIDRs match regardless of how the process binds.Why
Two separate pain points, one surface:
/mcp, a connection that just drops mid-init with no indication of why. Both make the limit painful to diagnose and impossible to self-remediate.Test plan
src/__tests__/ip-limiter.test.tsfor plain-IP, CIDR (IPv4 and IPv6), non-match, logging throttle,isAllowlisted()return values, and malformed/unknown inputs.src/__tests__/tool-config.test.tsaccept plain IP + CIDR + empty list; reject non-IP strings, invalid CIDR suffixes, and non-array allowlists.src/__tests__/sse-transport.test.tsassert the 429 body shape,Retry-Afterheader, and allowlist bypass for loopback.src/__tests__/rate-limit-response.test.tscovers the shared payload and JSON-RPC error frame shape (code is in the-32000..-32099server-error range).tsc --noEmitclean.npm run buildclean.Red -> green transcript
Before implementation (new tests failing against old code):
After implementation:
Full suite: