Files
RenewCMS/features/trusted_proxy_client_ip_for_rate_limiting.feature

99 lines
6.6 KiB
Gherkin

Feature: The rate limiter keys on the real connection address, trusting a forwarded client IP only from a configured proxy
As the maintainer of RenewCMS
I want the login/password-reset rate limiter to key on the raw TCP peer address by default, and
to honor a proxy-supplied client IP only when that peer address is a configured trusted proxy
So that an untrusted client can never choose which rate-limit counter it hits by sending its own
X-Real-IP or X-Forwarded-For header — which is possible today, because chimiddleware.RealIP
rewrites r.RemoteAddr from either header unconditionally, for every request
Background:
Given POST /v1/auth/login and POST /v1/auth/password-reset are each wrapped, inside
NewAuthRouter, by a middleware that runs before RateLimiter (api/middleware/rateLimit.go),
which keys its counters on r.RemoteAddr
And this slice replaces chimiddleware.RealIP on those two route groups with
TrustedProxyRealIP(trustedProxies []string) func(http.Handler) http.Handler
(api/middleware/trustedProxyRealIP.go)
And TrustedProxyRealIP reads the host portion of the request's current r.RemoteAddr — the raw
TCP connection's peer address, as set by net/http before any middleware runs — and compares
it against the configured trustedProxies list
And when that connection address is in trustedProxies and the request carries an X-Real-IP
header, TrustedProxyRealIP rewrites r.RemoteAddr's host to the header's value before calling
the next handler, so RateLimiter downstream keys its counter on the forwarded address instead
And when that connection address is not in trustedProxies, r.RemoteAddr is left untouched and
any X-Real-IP or X-Forwarded-For header present on the request is ignored entirely — it has
no effect on which counter the request is recorded against
And TrustedProxyRealIP never reads X-Forwarded-For, from a trusted proxy or otherwise; only
X-Real-IP is consulted
And when the connection address is a trusted proxy but the request has no X-Real-IP header,
r.RemoteAddr is left untouched, so the request is keyed on the proxy's own address
And a new environment variable, TRUSTED_PROXIES, is a semicolon-separated list of IP addresses
(matching the CORS_ALLOWED_ORIGINS parsing convention), given a default of "" in
cmd/server/main.go's envDefaults (not requiredEnvVars, so existing deployments keep working
unchanged, always keying on the raw connection address), read by config.NewSettingsFromEnv
into a new config.Settings.TrustedProxies ([]string) field, documented in .env.example and
docs/configuration.md
And docs/deployment.md's nginx example sets `proxy_set_header X-Real-IP $remote_addr;` (the
real peer, not $proxy_add_x_forwarded_for) and its Caddy example sets the equivalent header
from the real peer, and both examples note that TRUSTED_PROXIES must list the proxy's own
address (typically 127.0.0.1 when the proxy and RenewCMS share a host) for the forwarded
header to be honored
And this is a transport-level concern with no domain or use-case involvement: neither
TrustedProxyRealIP nor RateLimiter imports internal/domain, internal/infrastructure/useCases,
persistence or mailer
Scenario: With no trusted proxies configured, a forwarded header changes nothing
Given TRUSTED_PROXIES is empty and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a client connecting from "203.0.113.5" sends POST /v1/auth/login with header
X-Real-IP: "198.51.100.1"
And the same client immediately sends a second POST /v1/auth/login, still with
X-Real-IP: "198.51.100.1"
Then the first response is not rejected by rate limiting and the second is 429 — the counter is
keyed on the connection address "203.0.113.5", not the header value
Scenario: An untrusted connection address cannot use the header to dodge its own limit
Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a client connecting from "203.0.113.5" (not in TRUSTED_PROXIES) sends POST /v1/auth/login
with header X-Real-IP: "1.1.1.1"
And the same client sends a second POST /v1/auth/login with a different header,
X-Real-IP: "2.2.2.2"
Then the first response is not rejected by rate limiting and the second is 429 — both requests
are counted against the connection address "203.0.113.5", regardless of the differing header
values, because "203.0.113.5" is not a trusted proxy
Scenario: A configured trusted proxy's forwarded header is honored
Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a request arrives with connection address "10.0.0.1" and header
X-Real-IP: "198.51.100.9"
And a second request arrives with connection address "10.0.0.1" and the same header,
X-Real-IP: "198.51.100.9"
Then the first response is not rejected by rate limiting and the second is 429 — both are
counted against the forwarded client "198.51.100.9", not against the proxy's own address
Scenario: Two real clients behind the same trusted proxy get independent counters
Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a request arrives with connection address "10.0.0.1" and header
X-Real-IP: "198.51.100.9", consuming that forwarded client's limit
And a second request arrives with connection address "10.0.0.1" and header
X-Real-IP: "198.51.100.10"
Then the second request is not rejected by rate limiting — it is tracked separately from
"198.51.100.9"
Scenario: A trusted proxy's own X-Forwarded-For header is never consulted
Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a request arrives with connection address "10.0.0.1" and header
X-Forwarded-For: "198.51.100.9" (no X-Real-IP header)
And a second such request arrives with the same connection address and the same
X-Forwarded-For header
Then the first response is not rejected by rate limiting and the second is 429 — both are
counted against the proxy's own connection address "10.0.0.1", because X-Forwarded-For is
never read
Scenario: A trusted proxy with no forwarded header falls back to its own address
Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a request arrives with connection address "10.0.0.1" and no X-Real-IP header
And a second such request arrives, also from connection address "10.0.0.1" with no
X-Real-IP header
Then the first response is not rejected by rate limiting and the second is 429 — both are
counted against "10.0.0.1" itself