web: Rate limiters key on the TCP peer, ignoring RealIpLayer's resolved client IP #91
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
rosa/vernier#91
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Every route group in
crates/web/src/routes.rsbuilds its governor with thePeerIpextractor, which reads onlyConnectInfo<SocketAddr>— the raw TCP peer address. Meanwhile the middleware stack installsRealIpLayer(routes.rs:55), which resolves the forwarded client IP into areal::RealIprequest extension that nothing ever reads.Behind a reverse proxy, every request shares the proxy's peer IP, so each rate limiter collapses into a single global bucket:
If the app is instead deployed directly exposed,
PeerIpis correct andRealIpLayeris dead weight. Either way the two layers currently disagree about who the client is.Suggested fix: decide the intended deployment topology, then either
extractor::Extension<T>(which can key offreal::RealIp) orSmartIp, orRealIpLayerif the server genuinely terminates client TCP connections itself.Note
RealIpLayer::default()only trusts forwarded headers when the peer is a private IP, which matters when choosing the trust configuration for the proxy.Agent Brief
Category: bug
Summary: Rate limiters key on the TCP peer, so behind the Fly.io proxy every request shares one bucket — rekey them on the resolved client IP.
Deployment topology (decided): production runs behind the Fly.io proxy, which terminates client connections and forwards to the app over Fly's private network. The TCP peer the app sees is always
fly-proxy, never the client. Recorded indocs/adr/0012-web-server-runs-behind-the-flyio-proxy.md— read it before starting.Current behavior:
Every governor in the web router is built with a
PeerIpextractor, which keys onConnectInfo<SocketAddr>— the raw TCP peer. ARealIpLayerin the outer middleware stack resolves the forwarded client IP into areal::RealIprequest extension, but nothing reads it. Behind the proxy, all requests share the proxy's peer IP, so each per-IP limiter collapses into a single global bucket: the auth limiter's 10/min becomes a site-wide login cap, and no individual attacker is ever isolated below the shared budget.Desired behavior:
Every governor keys on the resolved client IP carried by
RealIpLayer, so limits apply per real client rather than per proxy. Concretely:real::RealIpextension. axum-governor exposesextractor::Extension<T>(key offreal::RealIp) andSmartIp; pick whichever cleanly satisfies the fallback rule below. Keep each group's existing quota unchanged.RealIpLayerinstalled, and keep it ordered before the governors so thereal::RealIpextension is populated when a governor extracts its key. (It already sits in the outerServiceBuilder, which wraps the per-route governor layers, so the current ordering is correct — verify it stays that way.)Extension<RealIp>on a missing extension must not fail open.RealIpLayer's forwarded-header trust is correct for Fly. On Fly the app's peer is a private address and the proxy sets the forwarded headers, soRealIpLayer::default()'s "trust forwarded headers only from a private-IP peer" heuristic should match — verify against what therealcrate actually reads and whatfly-proxysends (X-Forwarded-For/Fly-Client-IP). Do not widen trust to accept forwarded headers from public peers.Key interfaces:
GovernorConfigBuilderusage in the web router — swap.with_extractor(PeerIp::default())for a resolved-client-IP extractor across every route group; quotas and.expect_connect_info()(still needed for the peer-IP fallback) otherwise unchanged.RealIpLayer/real::RealIp— the extension the governors now consume; keep the layer, verify its trust config and header sources against Fly.axum_governor::extractor—Extension<T>andSmartIpare the candidate extractors.Acceptance criteria:
Fly-Client-IP/X-Forwarded-Forset and asserts one client's 429 doesn't limit the other).real::RealIpis actually consumed by the limiters (no deadRealIpLayer).RealIpLayerstill runs before the governors.format,clippy, andcimise tasks pass.Out of scope:
realdependency (it's now load-bearing).Correction to the agent brief
Implemented in #106. The mechanism differs from the brief above after reading the two crates' source:
Extension<real::RealIp>.RealIpLayer::default()honors a rawX-Real-IPand the leftmostX-Forwarded-Forentry with no peer-trust gating — both client-controllable. Fly appends the real client to the right ofX-Forwarded-Forand doesn't strip a client-suppliedX-Real-IP, so keying on that extension would let any client forge a fresh rate-limit bucket per request (an unlimited-bucket bypass). The brief's note thatRealIpLayer"only trusts forwarded headers from a private peer" was wrong about this crate.SmartIpinstead, configured with the private-network CIDRs as trusted proxies. It readsX-Forwarded-Foronly when the TCP peer is trusted and takes the rightmost untrusted entry — the client Fly appends — which a client can't spoof past. Missing header → peer-IP fallback; forwarding header from an untrusted peer → 400 Bad Request (fail closed). The brief said 500; axum-governor mapsUntrustedProxyto 400.RealIpLayerand therealcrate are removed (not kept/consumed). Addedipnetfor the CIDR type — already in the build via axum-governor.Trust boundary and the full rationale are recorded in
docs/adr/0012-web-server-runs-behind-the-flyio-proxy.md.