fix: rate limiting X-Real-IP from the real peer
This commit is contained in:
1 parent
9b47685002
commit
0e050c36ff
11 files changed
+439
-8
No files matched your search
@@ -12,3 +12,4 @@ UPLOAD_DIR=./data/uploads
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
TRUSTED_PROXIES=
|
||||
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func TrustedProxyRealIP(trustedProxies []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
realIP := r.Header.Get("X-Real-IP")
|
||||
if realIP != "" && isTrustedProxy(r.RemoteAddr, trustedProxies) {
|
||||
r.RemoteAddr = withRewrittenHost(r.RemoteAddr, realIP)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isTrustedProxy(remoteAddr string, trustedProxies []string) bool {
|
||||
host := hostOf(remoteAddr)
|
||||
for _, trusted := range trustedProxies {
|
||||
if host == trusted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hostOf(remoteAddr string) string {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return remoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func withRewrittenHost(remoteAddr string, newHost string) string {
|
||||
_, port, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return newHost
|
||||
}
|
||||
return net.JoinHostPort(newHost, port)
|
||||
}
|
||||
+4
-4
@@ -52,7 +52,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings) http.Handler {
|
||||
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
|
||||
@@ -61,13 +61,13 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
|
||||
r.Post("/register", loginHandler.Register)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(chimiddleware.RealIP)
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||
r.Post("/login", loginHandler.Login)
|
||||
})
|
||||
r.Post("/logout", loginHandler.Logout)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(chimiddleware.RealIP)
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(rateLimit.MaxAttempts, rateLimit.Window))
|
||||
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
|
||||
})
|
||||
@@ -94,7 +94,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Mount("/article", NewArticleRouter(container))
|
||||
})
|
||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit))
|
||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ var envDefaults = map[string]string{
|
||||
"UPLOAD_DIR": "./data/uploads",
|
||||
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
|
||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||
"TRUSTED_PROXIES": "",
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -28,6 +28,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
||||
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login` or `POST /v1/auth/password-reset` within the window, tracked independently per route. |
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the two routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for `POST /v1/auth/login` and `POST /v1/auth/password-reset` rate limiting. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
|
||||
+6
-2
@@ -100,7 +100,9 @@ With `ENVIRONMENT=production` the auth cookie is `Secure`, so **the site must be
|
||||
|
||||
```caddyfile
|
||||
cms.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
reverse_proxy localhost:8080 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -117,7 +119,7 @@ server {
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -125,6 +127,8 @@ server {
|
||||
|
||||
If RenewCMS runs on the same host as the proxy, consider firewalling port 8080.
|
||||
|
||||
Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address.
|
||||
|
||||
## Backups
|
||||
|
||||
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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
|
||||
@@ -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.
|
||||
@@ -29,6 +29,7 @@ type Settings struct {
|
||||
Port string
|
||||
Smtp SmtpSettings
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
||||
func (s Settings) IsProduction() bool {
|
||||
@@ -53,7 +54,7 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
|
||||
return Settings{
|
||||
Environment: os.Getenv("ENVIRONMENT"),
|
||||
CorsAllowedOrigins: parseCorsAllowedOrigins(os.Getenv("CORS_ALLOWED_ORIGINS")),
|
||||
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
|
||||
PublicURL: os.Getenv("PUBLIC_URL"),
|
||||
UploadDir: os.Getenv("UPLOAD_DIR"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
@@ -69,6 +70,7 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
MaxAttempts: authRateLimitMaxAttempts,
|
||||
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
|
||||
},
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -76,7 +78,7 @@ func intEnv(name string) (int, error) {
|
||||
return strconv.Atoi(os.Getenv(name))
|
||||
}
|
||||
|
||||
func parseCorsAllowedOrigins(raw string) []string {
|
||||
func parseSemicolonSeparatedList(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +134,37 @@ func TestNewSettingsFromEnvInvalidAuthRateLimitWindowSecondsReturnsError(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSettingsFromEnvEmptyTrustedProxiesIsNil(t *testing.T) {
|
||||
setBaseSettingsEnv(t)
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if settings.TrustedProxies != nil {
|
||||
t.Errorf("expected nil TrustedProxies, got %v", settings.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSettingsFromEnvParsesMultipleTrustedProxies(t *testing.T) {
|
||||
setBaseSettingsEnv(t)
|
||||
t.Setenv("TRUSTED_PROXIES", "10.0.0.1;127.0.0.1")
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
expected := []string{"10.0.0.1", "127.0.0.1"}
|
||||
if len(settings.TrustedProxies) != len(expected) {
|
||||
t.Fatalf("expected %d trusted proxies, got %v", len(expected), settings.TrustedProxies)
|
||||
}
|
||||
for i, proxy := range expected {
|
||||
if settings.TrustedProxies[i] != proxy {
|
||||
t.Errorf("expected trusted proxy %d to be %q, got %q", i, proxy, settings.TrustedProxies[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsIsProductionFalseForNonProductionEnvironment(t *testing.T) {
|
||||
settings := config.Settings{Environment: "development"}
|
||||
if settings.IsProduction() {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/middleware"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/hashing"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, trustedProxies []string) *chi.Mux {
|
||||
t.Helper()
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
loginHandler := auth.NewLoginHandler(
|
||||
token,
|
||||
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewAuthenticateUserUseCase(userRepo, hasher),
|
||||
true,
|
||||
)
|
||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(1, time.Hour))
|
||||
r.Post("/login", loginHandler.Login)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.TrustedProxyRealIP(trustedProxies))
|
||||
r.Use(middleware.NewRateLimiter(1, time.Hour))
|
||||
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
|
||||
})
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func sendTrustedProxyRequest(router *chi.Mux, remoteAddr string, headerName string, headerValue string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", bytes.NewBufferString(`{"username": "alice", "password": "correctpassword1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = remoteAddr
|
||||
if headerName != "" {
|
||||
req.Header.Set(headerName, headerValue)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestTrustedProxyClientIpForRateLimitingHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
|
||||
t.Run("With no trusted proxies configured, a forwarded header changes nothing", func(t *testing.T) {
|
||||
userRepo := newRateLimitAliceRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newTrustedProxyAuthRouter(t, userRepo, mailRepo, nil)
|
||||
|
||||
first := sendTrustedProxyRequest(router, "203.0.113.5:1", "X-Real-IP", "198.51.100.1")
|
||||
if first.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the first request to not be rate-limited, got %d", first.Code)
|
||||
}
|
||||
|
||||
second := sendTrustedProxyRequest(router, "203.0.113.5:1", "X-Real-IP", "198.51.100.1")
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the second request to be 429, got %d", second.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An untrusted connection address cannot use the header to dodge its own limit", func(t *testing.T) {
|
||||
userRepo := newRateLimitAliceRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newTrustedProxyAuthRouter(t, userRepo, mailRepo, []string{"10.0.0.1"})
|
||||
|
||||
first := sendTrustedProxyRequest(router, "203.0.113.5:1", "X-Real-IP", "1.1.1.1")
|
||||
if first.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the first request to not be rate-limited, got %d", first.Code)
|
||||
}
|
||||
|
||||
second := sendTrustedProxyRequest(router, "203.0.113.5:1", "X-Real-IP", "2.2.2.2")
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the second request to be 429, got %d", second.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A configured trusted proxy's forwarded header is honored", func(t *testing.T) {
|
||||
userRepo := newRateLimitAliceRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newTrustedProxyAuthRouter(t, userRepo, mailRepo, []string{"10.0.0.1"})
|
||||
|
||||
first := sendTrustedProxyRequest(router, "10.0.0.1:1", "X-Real-IP", "198.51.100.9")
|
||||
if first.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the first request to not be rate-limited, got %d", first.Code)
|
||||
}
|
||||
|
||||
second := sendTrustedProxyRequest(router, "10.0.0.1:1", "X-Real-IP", "198.51.100.9")
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the second request to be 429, got %d", second.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Two real clients behind the same trusted proxy get independent counters", func(t *testing.T) {
|
||||
userRepo := newRateLimitAliceRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newTrustedProxyAuthRouter(t, userRepo, mailRepo, []string{"10.0.0.1"})
|
||||
|
||||
first := sendTrustedProxyRequest(router, "10.0.0.1:1", "X-Real-IP", "198.51.100.9")
|
||||
if first.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the first request to not be rate-limited, got %d", first.Code)
|
||||
}
|
||||
|
||||
second := sendTrustedProxyRequest(router, "10.0.0.1:1", "X-Real-IP", "198.51.100.10")
|
||||
if second.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected a different forwarded client to not be rate-limited, got %d", second.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A trusted proxy's own X-Forwarded-For header is never consulted", func(t *testing.T) {
|
||||
userRepo := newRateLimitAliceRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newTrustedProxyAuthRouter(t, userRepo, mailRepo, []string{"10.0.0.1"})
|
||||
|
||||
first := sendTrustedProxyRequest(router, "10.0.0.1:1", "X-Forwarded-For", "198.51.100.9")
|
||||
if first.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the first request to not be rate-limited, got %d", first.Code)
|
||||
}
|
||||
|
||||
second := sendTrustedProxyRequest(router, "10.0.0.1:1", "X-Forwarded-For", "198.51.100.9")
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the second request to be 429, got %d", second.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A trusted proxy with no forwarded header falls back to its own address", func(t *testing.T) {
|
||||
userRepo := newRateLimitAliceRepo()
|
||||
mailRepo := newMockMailRepo()
|
||||
router := newTrustedProxyAuthRouter(t, userRepo, mailRepo, []string{"10.0.0.1"})
|
||||
|
||||
first := sendTrustedProxyRequest(router, "10.0.0.1:1", "", "")
|
||||
if first.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the first request to not be rate-limited, got %d", first.Code)
|
||||
}
|
||||
|
||||
second := sendTrustedProxyRequest(router, "10.0.0.1:1", "", "")
|
||||
if second.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected the second request to be 429, got %d", second.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A trusted connection address with no port rewrites RemoteAddr to the bare forwarded host", func(t *testing.T) {
|
||||
var capturedRemoteAddr string
|
||||
handler := middleware.TrustedProxyRealIP([]string{"10.0.0.1"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedRemoteAddr = r.RemoteAddr
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1"
|
||||
req.Header.Set("X-Real-IP", "198.51.100.9")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if capturedRemoteAddr != "198.51.100.9" {
|
||||
t.Fatalf("expected RemoteAddr to be rewritten to the bare forwarded host %q, got %q", "198.51.100.9", capturedRemoteAddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user