99 lines
6.6 KiB
Gherkin
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
|