Skip to content

P0: Add per-IP request rate limiting (#166) - #185

Open
dkijania wants to merge 3 commits into
mainfrom
feat/rate-limit
Open

P0: Add per-IP request rate limiting (#166)#185
dkijania wants to merge 3 commits into
mainfrom
feat/rate-limit

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

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.

Env var Default Meaning
RATE_LIMIT_MAX 600 Requests per client IP per window; 0 disables
RATE_LIMIT_WINDOW_MS 60000 Window length in ms

Design

  • Client IP from X-Forwarded-For (first hop) → X-Real-IP → socket address → shared unknown bucket. Run behind a proxy that sets X-Forwarded-For for correct per-client identification (documented).
  • Fixed-window in-memory counter, per-instance: with N replicas the effective limit is ~N × RATE_LIMIT_MAX. A shared store (Redis) for exact cross-replica limits is left as deployment hardening and noted in the docs.
  • Health checks (/healthcheck) are never throttled.
  • 429 responses carry 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 — clean
  • npm 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 — clean
  • npx prettier --debug-check . — exit 0

No new runtime dependency.

🤖 Generated with Claude Code

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
@dkijania dkijania added production-readiness Work toward making the API production-ready / publicly available P0 Blocker for public availability labels Jun 28, 2026
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

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), /healthcheck is exempt so liveness probes won't flap, RATE_LIMIT_MAX=0 is a clean off switch, and the 429 body is safely distinct from the Cannot query field string the Explorer keys on for fallback. 👍

One hardening worth doing before we call #166 done: X-Forwarded-For is trusted unconditionally, so a single abusive source can rotate the header to mint a fresh bucket per request and slip right past the limit — the exact thing #166 asks us to stop — and each forged value also adds a Map entry that only clears at window end (spoofable memory growth).

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 Access-Control-Allow-Origin from Yoga's useCORS (it runs on onResponse, so it should) — since the Explorer is cross-origin, an ACAO-less 429 would surface as an opaque CORS error instead of a clean 429; and (2) a quick test asserting /healthcheck never 429s would lock in the liveness exemption. Great addition overall.

dkijania and others added 2 commits July 17, 2026 00:26
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>
@dkijania

Copy link
Copy Markdown
Contributor Author

Thanks @SanabriaRusso — the XFF bypass was real and is fixed in 3932d3e / 9881c02. The framing that the fix is to bound the trust rather than drop the header was the useful part: dropping it would have collapsed NAT'd and LB-fronted clients onto one bucket, which is its own availability problem.

TRUST_PROXY hop count, counted from the right. Default 0 ignores forwarding headers entirely and keys on the socket address — the safe reading for a directly-exposed server. Set it to the hop count behind an LB and the client is read as the Nth entry from the right, so anything the caller prepended is ignored. X-Real-IP is gated the same way (it was equally spoofable), and a chain shorter than the hop count falls back to the socket rather than trusting a caller-supplied entry.

One deviation from your snippet: I threaded it through resolveRateLimitConfig instead of reading process.env inline, to keep the module's injectable-env convention — that's what let the bypass itself be tested rather than just reasoned about:

// 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 onResponse, so it applies to the short-circuited 429. Verified against a yoga instance configured the way production is:

allowed  -> 200 ACAO: "https://explorer.example.com"
limited  -> 429 ACAO: "https://explorer.example.com"

Pinned as a test, since it's the kind of thing a plugin-order change could quietly break. /healthcheck never 429s is now covered too.

One thing worth a second opinion: TRUST_PROXY=0 is secure-by-default but wrong for our own topology — the archives sit behind an LB, so an unset value buckets every client together. Rather than default to 1 (which would reintroduce the bypass for directly-exposed deployments), it warns once on the first forwarded request when TRUST_PROXY=0, so a misconfigured deploy says so instead of just throttling oddly. Same spirit as the startup warning you suggested for #184's CORS default. Shout if you'd rather it were louder — or an outright boot failure.

On /metrics and the exemption list (from your #191 note): leaving that for the #185#191 reconcile rather than exempting a path that 404s today — exempting a nonexistent route now would just be an unthrottled hole. Worth deciding there whether the fix is the exemption list or plugin ordering, since the merge plan currently assumes ordering and your note assumes the list; we should land one, not half of each.

dkijania added a commit that referenced this pull request Jul 17, 2026
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>
dkijania added a commit that referenced this pull request Jul 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P0 Blocker for public availability production-readiness Work toward making the API production-ready / publicly available

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P0: Add rate limiting (edge or in-process)

2 participants