Repository navigation
fix(graphql-server): attribute requests to the trusted client address, not a forged X-Forwarded-For - #1923
fix(graphql-server): attribute requests to the trusted client address, not a forged X-Forwarded-For#1923pyramation wants to merge 3 commits into
Conversation
…, not a forged X-Forwarded-For req.clientIp was set by request-ip from the leftmost X-Forwarded-For entry while Express trusted every proxy, so a caller could choose the address that auth and graphile put into jwt.claims.ip_address and rotate it per request. Resolve the client address once per request with clientIpMiddleware (clientIpFrom + trustedProxyHops, rightmost-minus-hops, never the leftmost) and read it everywhere: pgSettings, admission control and the refusal recorder. Replace SERVER_TRUST_PROXY with TRUSTED_PROXY_HOPS in the env config; drop request-ip.
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Review complete. 🟠 1 high · 🟡 1 medium 📍 Findings outside the diff (2) — 🟠 1 high · 🟡 1 medium — defects on lines GitHub can't attach comments to🟠 High — Aborted queued requests permanently leak concurrency slots · admission-control.ts:96–105 · // graphql/server/src/middleware/admission-control.ts
96 let lease: AdmissionLease;
97 try {
98 lease = await concurrency.acquire(databaseId, {
99 limit: protection.maxConcurrentRequests,
100 queueWaitMs: protection.maxQueueWaitMs
101 });
102 } catch (e) {
103 next(e);
104 return;
105 }The release listeners are attached only after 🟡 Medium — Update the stale The PR renamed 🧹 Nitpicks (1) — 🟢 1 low
The change introduces a new client-IP middleware in
One high-severity issue (concurrency-slot leak on aborted queued requests) and a stale Reviewed commit: d225a53 |
Summary
Bug.
graphql/servermountedrequest-ip's middleware (which setsreq.clientIpfrom the leftmostX-Forwarded-Forentry) and set Expresstrust proxyto an always-true predicate.auth.tsandgraphile.tsthen putreq.clientIpinto the transaction asjwt.claims.ip_address. Any caller could therefore sendX-Forwarded-For: 1.2.3.4and pick — and rotate per request — the address every per-IP limit and audit row in the database is keyed on. Admission control and the refusal recorder already did this correctly withclientIpFrom(req, hops)(rightmost-minus-trusted-hops, never the leftmost), so the server had two notions of "the client's address".Fix. One trusted client address per request, resolved once and read everywhere:
auth.ts/graphile.ts/buildPgSettingskeep readingreq.clientIp, which now carries the trusted address.createAdmissionControlMiddleware()andrecordRefusal()readclientIpOf(req)instead of re-deriving hops themselves; theirtrustedProxyHopsoptions and thereq.app.get('trust proxy') ? 1 : 0guess are gone.ServerOptions.trustProxy: boolean→ServerOptions.trustedProxyHops: number(default 0), envSERVER_TRUST_PROXY→TRUSTED_PROXY_HOPS, parsed by@pgpmjs/envand so by@constructive-io/graphql-env'sgetEnvOptions. The rawprocess.env.TRUSTED_PROXY_HOPSreader in express-context is removed.request-ip/@types/request-ipdropped (graphql/server, and an unused declaration in graphile-settings).pnpm-lock.yamlonly loses those entries.Deployment
TRUSTED_PROXY_HOPSmust equal the number ofX-Forwarded-For-appending proxies you control between the client and this server — proxies that discard the caller-supplied header and append the peer address they themselves saw.0(default) trusts only the TCP peer; one reverse proxy/ingress directly in front of the server is1. Counting higher than the deployment really has lets a direct caller choose its own address, so when in doubt count lower. The edge must also preserve the client source address all the way to the first trusted proxy (no SNAT in between), otherwise the trusted entry is an internal hop's address and every per-IP limit collapses onto it. Deployments that setSERVER_TRUST_PROXY=truebehind a single proxy needTRUSTED_PROXY_HOPS=1.Nothing inside this repo adds a hop: the GraphQL server is served directly by Express, with no proxy process in front of it.
Tests
graphql/server-test/__tests__/client-ip.integration.test.ts— real server viagetConnections, a fixture queryrequestClientIpthat returnscurrent_setting('jwt.claims.ip_address'): behind 1 hop a forged leftmost entry yields the proxy-appended address, cannot be rotated, absent/malformed header falls back to the socket peer; with 0 hops XFF is ignored entirely.packages/express-context/__tests__/client-ip.test.ts—req.clientIpfor 0 hops, 1 hop (forged leftmost, rotation), absent/malformed/short chain, non-positive hop count.admission-control.test.tsupdated: keys onreq.clientIp, falls back to the socket peer (never the header) when the middleware did not run.Before (main
a94434095+ these tests, local run):After (this branch):
Lint clean on the touched packages;
pnpm --filter ... buildgreen for@pgpmjs/types,@pgpmjs/env,@pgpmjs/server-utils,@constructive-io/express-context,@constructive-io/graphql-server,graphql-server-test.Link to Devin session: https://app.devin.ai/sessions/959661f0d69640789bd6b38550d379b7
Open in Devin Desktop: https://app.devin.ai/desktop/session/959661f0d69640789bd6b38550d379b7?variant=devin
Requested by: @pyramation