Repository navigation
API hardening: trusted client IP, bounded bodies, limiter integrity, and no RPC-key leakage #111
Description
Problem
The API has several independent resource-exhaustion and limit-bypass gaps. They are cheap to fix together because they share the same request helpers.
-
Rate-limit identity is inconsistent.
app/src/lib/profiles/http.ts:5correctly usesfly-client-ip, then the lastx-forwarded-forhop. Thirteen routes instead take the first XFF entry:bridge/quote,bridge/status,launch/chart-provider,launch/edit,launch/edit/nonce,launch/image,launch/meta,launch/sync,posts,posts/mod,posts/report,presence, andrpc. The first entry is whatever the caller sent, so a caller can rotate it to get a fresh bucket when the app is behind a proxy that does not setfly-client-ip(self-host, another platform, or a misconfigured deployment). -
The shared limiter wipes every bucket at 20,000 keys.
app/src/lib/launchpad/editServer.ts:15-27callsbuckets.clear(), and expired buckets are never pruned. Once the process has seen 20k distinct keys, the next new key resets every active limit. The RPC token bucket has the same pattern at 10k (app/src/app/api/rpc/route.ts:37-52). -
Unauthenticated JSON endpoints buffer unbounded bodies.
launch/edit,launch/edit/nonce,launch/meta,posts,posts/report,posts/modandrpccallreq.json()directly. Next route handlers have no body cap, so a chunked request can make the process buffer arbitrary bytes before validation. The multipart image route checkscontent-lengthonly; a chunked upload bypasses that check. -
The sync response can leak the keyed RPC URL.
pollAll()returnsreason: errMessage(e)for a failed chain (indexer.ts:665);POST /api/launch/syncreturns that object unauthenticated. Viem errors quote the upstream URL, and the paid RPC URL carries its API key in the path. The DB copy is redacted (indexer.ts:837), the response is not. The tx branch also returns rawerr.message. -
The RPC proxy buffers unbounded request and response bodies. A batch can be up to 20 calls; the body and upstream response are read with
req.json()/res.text()with no byte cap.eth_getLogscan return a very large response.
Impact
- Per-IP limits can be bypassed or reset, so expensive endpoints (sync, bridge quote, chart, image, posts, RPC) can be driven harder than intended.
- A single large body can exhaust server memory.
- The RPC provider URL/API key can be disclosed to any caller who can make an upstream call fail.
- The RPC proxy can be used to make the process buffer a huge response.
Proposed fix
- Move the trusted-IP helper and body readers into
app/src/lib/http.ts; keepprofiles/http.tsas a re-export shim so existing imports keep working. - Use
clientIp()in every route that currently parses XFF itself. - Replace the rate limiter's
clear()with expired-first pruning + oldest-first eviction; do the same for the RPC token bucket. - Use
readJson()for the object endpoints and a boundedreadJsonValue()for the RPC batch; use boundedreadFormData()for the image route. - Cap the RPC upstream response; reject over-cap responses with 502.
- Redact sync errors in the route and in
pollAllresults; keep full details out of the unauthenticated response.
Acceptance criteria
- No API route takes the first
x-forwarded-forentry as the client identity. - An oversized declared or streamed body is rejected without buffering past the cap; a chunked oversized multipart upload is rejected too.
- A limiter overflow cannot reset unrelated buckets; the RPC token bucket does not clear all state.
/api/launch/syncnever returns a URL/API key from an RPC error.- Unit and source-contract tests cover the helpers and the route wiring;
npm test,typecheck,lint,buildpass.
Non-goals
- No change to the existing per-route limits or quotas.
- No change to signatures, admin auth, or DB schema.
Activity