fix(web): Key rate limiters on the resolved client IP behind the proxy #106

Merged
rosa merged 1 commit from fix/91-rate-limit-real-ip into main 2026-07-05 18:09:10 +00:00
Owner

Closes #91.

Problem

Every governor in the web router keyed on PeerIp — the raw TCP peer — while a RealIpLayer resolved a client IP into an extension that nothing read. Behind the Fly.io proxy the app's peer is always fly-proxy, so every request shared one IP and each per-IP limiter collapsed into a single global bucket: the auth limiter's 10/min became a site-wide login cap, and no individual attacker was ever isolated below the shared budget.

Topology decision recorded in ADR 0012.

Change

  • Rekey all 8 governors on axum-governor's SmartIp, configured with the private-network CIDRs as trusted proxies. SmartIp consults X-Forwarded-For only when the TCP peer is trusted and takes the rightmost untrusted entry — the client address fly-proxy appends, which a client can't spoof past by prepending its own entries.
    • No proxy in front → falls back to the peer IP.
    • Untrusted peer carrying forwarding headers → rejected 400 Bad Request (fail closed), never trusted.
    • IPv6 keys are masked to /56, so a client can't trivially rotate identity within its own block.
  • Drop RealIpLayer and the real crate. RealIpLayer::default() honors a raw X-Real-IP and the leftmost X-Forwarded-For entry with no peer-trust gating — both client-controllable — so keying on it would let any client forge a fresh bucket per request. SmartIp is the spoof-resistant equivalent.
  • Add ipnet for the CIDR type (already in the build as a transitive dep of axum-governor).

Trust boundary

Trust rests on a deployment guarantee: only fly-proxy reaches the app's internal port, so any private peer is the proxy. Exposing the app without a proxy would require revisiting ADR 0012 and the trusted-CIDR set together — called out in the ADR.

Tests

Extractor-level unit tests covering the keying contract:

  • client-behind-trusted-proxy → keys on the forwarded client
  • client-prepended spoof → ignored (rightmost untrusted wins)
  • no forwarding header → peer-IP fallback
  • forwarding header from an untrusted peer → UntrustedProxy

format, clippy, and ci all pass.

Closes #91. ## Problem Every governor in the web router keyed on `PeerIp` — the raw TCP peer — while a `RealIpLayer` resolved a client IP into an extension that nothing read. Behind the Fly.io proxy the app's peer is always `fly-proxy`, so every request shared one IP and each per-IP limiter collapsed into a single global bucket: the auth limiter's 10/min became a **site-wide** login cap, and no individual attacker was ever isolated below the shared budget. Topology decision recorded in **ADR 0012**. ## Change - Rekey all 8 governors on axum-governor's `SmartIp`, configured with the private-network CIDRs as trusted proxies. `SmartIp` consults `X-Forwarded-For` **only** when the TCP peer is trusted and takes the **rightmost untrusted** entry — the client address `fly-proxy` appends, which a client can't spoof past by prepending its own entries. - No proxy in front → falls back to the peer IP. - Untrusted peer carrying forwarding headers → rejected **400 Bad Request** (fail closed), never trusted. - IPv6 keys are masked to /56, so a client can't trivially rotate identity within its own block. - **Drop `RealIpLayer` and the `real` crate.** `RealIpLayer::default()` honors a raw `X-Real-IP` and the *leftmost* `X-Forwarded-For` entry with no peer-trust gating — both client-controllable — so keying on it would let any client forge a fresh bucket per request. `SmartIp` is the spoof-resistant equivalent. - Add `ipnet` for the CIDR type (already in the build as a transitive dep of axum-governor). ## Trust boundary Trust rests on a deployment guarantee: only `fly-proxy` reaches the app's internal port, so any private peer is the proxy. Exposing the app without a proxy would require revisiting ADR 0012 and the trusted-CIDR set together — called out in the ADR. ## Tests Extractor-level unit tests covering the keying contract: - client-behind-trusted-proxy → keys on the forwarded client - client-prepended spoof → ignored (rightmost untrusted wins) - no forwarding header → peer-IP fallback - forwarding header from an untrusted peer → `UntrustedProxy` `format`, `clippy`, and `ci` all pass.
fix(web): Key rate limiters on the resolved client IP behind the proxy
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
b2790d24e5
Every governor keyed on PeerIp — the raw TCP peer — while a RealIpLayer
resolved a client IP that nothing read. Behind the Fly.io proxy (ADR
0012) every request shares the proxy's peer IP, collapsing each per-IP
limiter into one global bucket: the auth limiter's 10/min became a
site-wide login cap, and no attacker was ever isolated.

Rekey all governors on axum-governor's SmartIp, trusting the private
CIDRs the app's only ingress connects from. SmartIp reads
X-Forwarded-For only from a trusted peer and takes the rightmost
untrusted entry — the client fly-proxy appends — which a client can't
spoof past. Missing header falls back to the peer IP; a forged header
from an untrusted peer is rejected 400.

Drop RealIpLayer and the `real` crate: RealIpLayer::default() honors a
raw X-Real-IP and the leftmost X-Forwarded-For entry with no peer
gating, so keying on it would let any client forge a fresh bucket.
Add ipnet (already in the build via axum-governor) for the CIDR type.

Closes #91
rosa merged commit d5f69cb059 into main 2026-07-05 18:09:10 +00:00
rosa deleted branch fix/91-rate-limit-real-ip 2026-07-05 18:09:10 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rosa/vernier!106
No description provided.