Refac/domain driven #1

Merged
fsylvain merged 40 commits from refac/domain-driven into main 2026-09-25 16:15:15 +02:00
11 changed files with 439 additions and 8 deletions
Showing only changes of commit 0e050c36ff - Show all commits

No files matched your search

+1
View File
@@ -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=
+44
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -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() {
+1
View File
@@ -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
View File
@@ -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.
+4 -2
View File
@@ -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
}
+31
View File
@@ -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)
}
})
}