P0: Add per-IP request rate limiting (#166) - #185
Conversation
A public GraphQL endpoint with no throttle lets a single client monopolise the server and the backing Postgres. Add a global, per-client-IP rate limiter that runs on every request before GraphQL parsing, rejecting over-limit traffic with HTTP 429 as cheaply as possible. - RATE_LIMIT_MAX (requests per client per window, default 600; 0 disables) - RATE_LIMIT_WINDOW_MS (window length in ms, default 60000) The client IP is taken from X-Forwarded-For (first hop), then X-Real-IP, then the socket address, falling back to a shared `unknown` bucket so unproxied traffic is still bounded. Health checks are never throttled. The fixed-window counter is in-memory and per-instance; a shared store for exact cross-replica limits is left as deployment hardening and noted in the docs. Implemented as a custom plugin rather than @envelop/rate-limiter, whose directive model bakes limits into the schema and is per-field rather than a global per-IP bucket. Malformed env values fall back to safe defaults. Unit tests cover config parsing, the windowing/reset/prune logic with an injected clock, and prove end-to-end through Yoga that the (max+1)th request from an IP gets 429 while other clients are unaffected. Closes #166. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
|
Nice work — this lands the shape we want for the mina-explorer client: the 600 req / 60s default comfortably absorbs the Explorer's per-page fallback bursts (full→basic→minimal + per-block detail is well under 600/min), One hardening worth doing before we call #166 done: The subtlety is that honoring XFF is also what keeps NAT'd / LB-fronted Explorer users in their own buckets instead of collapsing onto one shared IP — so the fix isn't to drop XFF, it's to bound the trust with a hop-count knob and fall back to the socket address: // TRUST_PROXY = number of trusted proxy hops in front of us.
// Behind an LB/ingress (our deployment): set it to the hop count so real client IPs are used
// (per-user Explorer buckets preserved). Set 0 on a directly-exposed server to ignore XFF.
function clientId(request, serverContext) {
const hops = Number(process.env.TRUST_PROXY ?? 0);
if (hops > 0) {
const xff = request.headers.get('x-forwarded-for')?.split(',').map(s => s.trim()).filter(Boolean) ?? [];
const ip = xff[xff.length - hops]; // count from the right → ignores attacker-prepended entries
if (ip) return ip;
}
const ctx = serverContext;
return ctx?.req?.socket?.remoteAddress ?? ctx?.socket?.remoteAddress ?? 'unknown';
}Two smaller, non-blocking notes: (1) worth confirming the short-circuited 429 still picks up |
X-Forwarded-For was trusted unconditionally and read left-to-right, so a single source could rotate the header to mint a fresh bucket per request and bypass the limit entirely — the abuse this plugin exists to stop — while each forged value also added a Map entry that only cleared at window end. Dropping the header isn't the fix: honouring it is what keeps NAT'd and LB-fronted clients in their own buckets rather than collapsing onto one address. Instead TRUST_PROXY declares how many proxy hops sit in front of the API, and the client is read as that many entries from the right — the portion our own proxies appended — so prepended values are ignored. It defaults to 0, which ignores forwarding headers and keys on the socket address: the safe reading for a directly-exposed server. X-Real-IP is gated the same way, and a chain shorter than the hop count falls back to the socket rather than trusting a caller-supplied entry. Also pins two contracts the limiter must not regress: the healthcheck is never throttled, and the short-circuited 429 still carries CORS headers (verified — yoga's CORS runs on onResponse), so cross-origin clients get a readable 429 instead of an opaque CORS error. Addresses review feedback on #185. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The secure default is wrong for the expected production topology, where it collapses every client onto the load balancer's address. That symptom surfaces only as unexplained throttling, so warn once on the first forwarded request — once rather than per request, since this sits on the hot path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @SanabriaRusso — the XFF bypass was real and is fixed in
One deviation from your snippet: I threaded it through // one LB in front, so only the last entry is ours; varying the prepended value must not help
assert.strictEqual((await request('a.a.a.a, 7.7.7.7')).status, 200);
assert.strictEqual((await request('b.b.b.b, 7.7.7.7')).status, 200);
assert.strictEqual((await request('c.c.c.c, 7.7.7.7')).status, 429);Your ACAO question — confirmed, no fix needed. Your reasoning was right: yoga's CORS runs on Pinned as a test, since it's the kind of thing a plugin-order change could quietly break. One thing worth a second opinion: On |
Three corrections, all of which would have misled operators: CORS. The checklist told operators to set an allowlist "or leave unset — not *", which would block every cross-origin browser client, the mina-explorer included, with no server-side symptom. For a public read-only API over already-public data, CORS_ORIGIN=* is the correct setting rather than a lapse: CORS constrains browsers, not curl, so it is not an access control. Adds a section making the choice explicit and notes these controls arrive in 1.0.0 — on 0.0.x, CORS_ORIGIN defaults to '*', so the protections table describes a version most operators are not yet running. TRUST_PROXY. The doc described X-Forwarded-For as read "first hop", which is the behaviour removed in #185 as a rate-limit bypass. Documents the hop-count model and the deny-by-default reading instead. Read replicas. The README claimed the server "fans queries across" multiple PG_CONN hosts. It does not: postgres.js scopes hostIndex per Connection (src/connection.js:89), so every pooled connection starts at host[0] and only advances on failure — failover, not fan-out. As written it promised read scaling that adding replicas cannot deliver. Addresses review feedback on #186. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Y row Scaling told operators to "add read replicas and point PG_CONN at them before scaling the API further", which reads as added read capacity. It isn't: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Extra hosts buy redundancy, not throughput — real read scaling needs a balancer in front of Postgres. The failover section now says so plainly rather than leaving "connects to an available host" open to the throughput reading. Adds a version scope note. Nearly everything the runbook says to observe or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are no-ops, and SIGTERM skips the drain. A runbook that misdirects mid- incident is worse than no runbook, and the published image today is 0.0.6. Scoping by version rather than by in-flight PR numbers keeps the note true after the merge train lands. Splits the 429 incident row: after #185, mass 429s across unrelated clients most likely means TRUST_PROXY is unset behind a gateway, collapsing every client into one bucket — a different fix from a single client exceeding the limit. Addresses review feedback on #197. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What & why
Part of the production-readiness epic (#163). Closes #166.
A public GraphQL endpoint with no throttle lets a single client monopolise the server and the backing Postgres. This adds a global, per-client-IP rate limiter that runs on every request before GraphQL parsing, rejecting over-limit traffic with HTTP 429 as cheaply as possible.
RATE_LIMIT_MAX6000disablesRATE_LIMIT_WINDOW_MS60000Design
X-Forwarded-For(first hop) →X-Real-IP→ socket address → sharedunknownbucket. Run behind a proxy that setsX-Forwarded-Forfor correct per-client identification (documented).RATE_LIMIT_MAX. A shared store (Redis) for exact cross-replica limits is left as deployment hardening and noted in the docs./healthcheck) are never throttled.Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining.Why not @envelop/rate-limiter
That library is directive-based: limits are baked into the schema per field and it's per-field rather than a global per-IP bucket, plus it requires a schema/codegen change. A small custom plugin gives a true global per-IP DoS bucket that's env-tunable with no schema impact. (Discussed and chosen during implementation.)
Testing
npm run build— cleannpm run test:unit— all pass (config parsing; windowing/reset/prune with an injected clock; end-to-end through Yoga proving the (max+1)th request returns 429 and other clients are unaffected)npm run lint— cleannpx prettier --debug-check .— exit 0No new runtime dependency.
🤖 Generated with Claude Code