security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371) #1
Reference in New Issue
Block a user
Delete Branch "agent-security-engineer/freno-dev:freno-dev/FRE-371-trusted-client-ip-clean"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The rate-limit key and the bug-report abuse quota were derived from the leftmost
x-forwarded-forhop, withx-real-ipread unconditionally. In our topology (client → Cloudflare → same-host origin proxy → app) both proxies append, so the leftmost hop is whatever the sender typed: one scripted loop rotatingX-Forwarded-For: <random>minted a fresh rate-limit bucket per request and unlimited bug-report quota.Addresses are never truncated (a /24-truncated key pools attackers with the innocents behind them).
Share-vs-copy, stated deliberately: the parsing primitives are a port of the ones FRE-366 reviewed in
request-client.ts, not an import.resolveClientIpcarries a standing rule (1d9287a) that it never feeds a security decision, it only exposes truncated prefixes while rate limiting needs the full address, and it ships on an analytics branch still under review. If the two drift,trusted-client-ip.tsis the one security paths follow. Deploy-side precondition (origin reachable only via Cloudflare + the same-host proxy) is documented in the file header.Evidence
In-run gate, branch
freno-dev/FRE-371-trusted-client-ip-clean@2fd2a4a:New pin: a rotating leftmost hop with a fixed
cf-connecting-ipresolves to the same client on every request — the spoof case the old read failed.Merge Danger
Door: two-way (revert restores the old read; no data migration).
Blast Radius: every caller of
getClientIP— login/password-reset/registration rate limits, audit-log IPs, bug-report quota. Behind the pinned proxy chain the resolved address becomes more accurate; a deployment that breaks the topology precondition (app directly internet-reachable) would degrade to the stricter "unknown" shared limits, not a looser one.getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for hop and read x-real-ip unconditionally. In our topology — client → Cloudflare → same-host origin proxy → app — both proxies APPEND, so the leftmost hop is whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit bucket per request, and unlimited bug-report quota via the same pattern in routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in the chain overwrites it. It is no longer read at all. New trust order (src/server/trusted-client-ip.ts, resolveTrustedClientIp): 1. a non-loopback socket peer IS the client — its headers are ignored; 2. cf-connecting-ip (Cloudflare overwrites it at the edge; parsed as an address or rejected); 3. x-forwarded-for counted from the RIGHT, discounting one hop for the Cloudflare edge address the origin proxy appended (the topology has exactly one trusted proxy below Cloudflare); 4. "unknown" — callers key stricter shared limits on it, so resolution fails closed toward a tighter bucket, never a looser one. Addresses are never truncated: a /24-truncated key would pool attackers with the innocents behind them. Share-vs-copy, stated deliberately: this is a port of the parsing primitives FRE-366 reviewed in request-client.ts, NOT an import. resolveClientIp carries a standing rule (1d9287a) that it never feeds a security decision; lifting that rule for these callers would couple an access-control change to an analytics branch under review, and the analytics helper only exposes truncated prefixes while rate limiting needs the full address. If the two drift, trusted-client-ip.ts is the one security paths follow. The deploy-side precondition (origin reachable only via Cloudflare + the same-host proxy) is documented in the file header; if the app is ever directly internet-reachable no in-app read can recover the true client. Tests updated deliberately, not to go green: rate-limit.test.ts now pins the rightmost-minus-one order, cf-connecting-ip precedence, peer-over-headers, x-real-ip rejection, and the spoof case (a rotating leftmost hop does not change the resolved client); report.test.ts pins the quota key stability under a rotating header. New trusted-client-ip.test.ts covers the helper's own edges: APIEvent wrapper, loopback forms, unparseable hops failing closed.