fix: rate limiting X-Real-IP from the real peer

This commit is contained in:
fsylvain committed 2026-09-25 06:49:55 +02:00
1 parent 9b47685002
commit 0e050c36ff
11 files changed
+439 -8

No files matched your search

@@ -0,0 +1,70 @@
# QA procedure: trusted_proxy_client_ip_for_rate_limiting
Package: `test` (black-box), extending `test/login_and_password_reset_rate_limiting_http_test.go`'s
setup (`mockUserRepo`, `stubHasher`, `mockMailRepo`, the full `useCases` set, `auth.NewHandler(...)`).
## Test infrastructure additions needed first
1. Implement `api/middleware.TrustedProxyRealIP(trustedProxies []string) func(http.Handler) http.Handler`.
2. In tests, build a `chi.NewRouter()` mirroring `NewAuthRouter`'s `/login` and `/password-reset`
groups: `middleware.TrustedProxyRealIP(trustedProxies)` then
`middleware.NewRateLimiter(maxAttempts, window)` then the handler. `chimiddleware.RealIP` is not
part of this router.
3. Simulate a request's connection address by setting `req.RemoteAddr = "<ip>:<port>"` (host:port,
matching real request shape) before calling the router. Simulate a proxy-forwarded header with
`req.Header.Set("X-Real-IP", "<ip>")` or `req.Header.Set("X-Forwarded-For", "<ip>")` as needed.
## Setup shared by every case
4. Call `validator.InitValidator()` once.
5. Build `mockUserRepo`, `mockMailRepo`, `stubHasher`, and the full `useCases` set, then
`auth.NewHandler(...)`.
6. Use `AUTH_RATE_LIMIT_MAX_ATTEMPTS=1` and a window covering the whole test
(`window=time.Hour`) throughout, so a second request from the same effective key is always the
one that trips 429 and no scenario needs to wait out a window.
7. Seed `mockUserRepo` with user "alice", a correct password hash, so `POST /v1/auth/login` can
return 200 where a scenario needs a non-429, non-403 baseline; a wrong-password body works
equally well to prove "not rejected by rate limiting" since only the status code being not 429
matters for these cases (assert `!= 429` in addition to whatever status login returns for the
given credentials).
## HTTP-level checks
8. `trustedProxies = nil` (empty). Send `POST /v1/auth/login` with `RemoteAddr = "203.0.113.5:1"`
and header `X-Real-IP: 198.51.100.1`. Assert the response is not `429`. Send a second, identical
request (same `RemoteAddr`, same header). Assert it is `429` — proves the header had no effect;
both requests hit the connection-address counter.
9. `trustedProxies = ["10.0.0.1"]`. Send `POST /v1/auth/login` with `RemoteAddr = "203.0.113.5:1"`
(not in the trusted list) and header `X-Real-IP: 1.1.1.1`. Assert not `429`. Send a second
request from the same `RemoteAddr` with header `X-Real-IP: 2.2.2.2` (a different value). Assert
it is `429` — proves an untrusted source can't split its own attempts across counters by varying
the header.
10. `trustedProxies = ["10.0.0.1"]`. Send `POST /v1/auth/login` with `RemoteAddr = "10.0.0.1:1"` and
header `X-Real-IP: 198.51.100.9`. Assert not `429`. Send a second request with the same
`RemoteAddr` and the same header. Assert `429` — proves the trusted proxy's forwarded client is
honored as the counter key.
11. `trustedProxies = ["10.0.0.1"]`. Send `POST /v1/auth/login` with `RemoteAddr = "10.0.0.1:1"` and
header `X-Real-IP: 198.51.100.9`, consuming that forwarded client's limit (assert not `429`).
Then send a second request with the same `RemoteAddr` but header `X-Real-IP: 198.51.100.10`.
Assert not `429` — a different forwarded client behind the same trusted proxy has its own
counter.
12. `trustedProxies = ["10.0.0.1"]`. Send `POST /v1/auth/login` with `RemoteAddr = "10.0.0.1:1"` and
header `X-Forwarded-For: 198.51.100.9` (no `X-Real-IP`). Assert not `429`. Send a second,
identical request. Assert `429` — proves `X-Forwarded-For` is never read, even from a trusted
proxy; both requests are keyed on the proxy's own address `10.0.0.1`.
13. `trustedProxies = ["10.0.0.1"]`. Send `POST /v1/auth/login` with `RemoteAddr = "10.0.0.1:1"` and
no forwarding header at all. Assert not `429`. Send a second, identical request. Assert `429` —
a trusted proxy with nothing to forward falls back to its own address as the key.
## Manual verification (not exercised by the Go test)
- Real behaviour behind the documented nginx and Caddy reverse proxies (`docs/deployment.md`) —
that each sets `X-Real-IP` from the actual peer address rather than a client-controlled value,
and that `TRUSTED_PROXIES` on the RenewCMS host is set to the proxy's real address (e.g.
`127.0.0.1` when co-located) — is not exercised by an in-process `httptest` router and should be
checked by hand against a real reverse-proxy deployment.
- `config.NewSettingsFromEnv` parsing `TRUSTED_PROXIES` from the environment (semicolon-separated,
matching `CORS_ALLOWED_ORIGINS`) is a use-case-free config concern; if a pure Go test for
`internal/infrastructure/config` already exists for `CORS_ALLOWED_ORIGINS`
(`test/runtime_settings_from_env_test.go`), extend it with a parallel case rather than adding it
here — flagged for the coder/qa stage to place correctly.