Skip to content

Rate limiter: IP allowlist + descriptive rejection payloads#42

Open
jpr5 wants to merge 3 commits intomainfrom
fix/rate-limit-allowlist
Open

Rate limiter: IP allowlist + descriptive rejection payloads#42
jpr5 wants to merge 3 commits intomainfrom
fix/rate-limit-allowlist

Conversation

@jpr5
Copy link
Copy Markdown
Contributor

@jpr5 jpr5 commented Apr 21, 2026

Summary

  • Adds server.allowlist — a list of IPs/CIDRs that bypass max_sessions_per_ip. Motivated by the Anthropic Assistant crawler (160.79.106.35) currently getting throttled. Empty by default; operators opt in per deployment.
  • Replaces the bare {error: "Too many sessions from this IP"} 429 on /sse and the silent transport.close() on /mcp init with a structured payload clients can actually react to: {error, reason, limit, currentCount, retryAfterSeconds, contact}. /mcp additionally sends a JSON-RPC 2.0 error frame at code -32005.
  • CIDR parsing delegated to 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:

  1. Anthropic's crawler can't complete its indexing when it exceeds the per-IP session cap, and we have no escape hatch for trusted crawlers or internal probes.
  2. Non-allowlisted clients that hit the cap either see a cryptic one-line error or, on /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

  • New unit tests in src/__tests__/ip-limiter.test.ts for plain-IP, CIDR (IPv4 and IPv6), non-match, logging throttle, isAllowlisted() return values, and malformed/unknown inputs.
  • Schema tests in src/__tests__/tool-config.test.ts accept plain IP + CIDR + empty list; reject non-IP strings, invalid CIDR suffixes, and non-array allowlists.
  • End-to-end tests in src/__tests__/sse-transport.test.ts assert the 429 body shape, Retry-After header, and allowlist bypass for loopback.
  • New src/__tests__/rate-limit-response.test.ts covers the shared payload and JSON-RPC error frame shape (code is in the -32000..-32099 server-error range).
  • Full test suite: 2734 passing / 2734.
  • tsc --noEmit clean.
  • npm run build clean.

Red -> green transcript

Before implementation (new tests failing against old code):

Test Files  4 failed (4)
     Tests  11 failed | 62 passed (73)

After implementation:

Test Files  4 passed (4)
     Tests  78 passed (78)

Full suite:

Test Files  176 passed (176)
     Tests  2734 passed (2734)

jpr5 added 3 commits April 21, 2026 08:58
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.
@jpr5 jpr5 force-pushed the fix/rate-limit-allowlist branch from 34d8dbc to d774bc6 Compare April 21, 2026 15:59
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.

1 participant