Files
Crussell/backend/mw/ratelimit_test.go
T
popertotsandSisyphus e9b34d0ad6 fix: dev-mock/prod parity + fail-closed config gates — 402 verification, cnon:sca binding validation, refund reconcile parity, SNAPSHOT_ENC_KEY fail-closed, webhook config gates, access-token startup validation, ClientIP validation
- mock: 400->402 for CARD_DECLINED_VERIFICATION_REQUIRED, cnon:sca- tokenize-result binding validated (prefix/amount/deny), RefundPayment exact-amount reconcile parity, ReplayPaymentByKey snapshot sanity, verify_mock_ legacy widening removed, listRefunds zero-time omits begin_time
- main.go: SNAPSHOT_ENC_KEY log.Fatalf in non-mock, webhook key-set-URL-unset log.Fatalf, SQUARE_ACCESS_TOKEN/LOCATION startup validation, empty-env base URL matches 2FA production interpretation
- mw: ClientIP rejects garbage/comma/port XFF values, documented trusted-proxy requirement

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

953 lines
38 KiB
Go

package mw
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"github.com/go-chi/chi/v5/middleware"
)
// TestProgressiveRateLimiter_CleanupRemovesStaleEntries verifies that
// IPs with no activity for 60s are cleaned up.
func TestProgressiveRateLimiter_CleanupRemovesStaleEntries(t *testing.T) {
prl := NewProgressiveRateLimiter()
prl.mu.Lock()
prl.requests["stale-ip"] = &ipProgressiveState{
timestamps: []time.Time{clock.Now().Add(-120 * time.Second)},
}
prl.mu.Unlock()
prl.Cleanup()
prl.mu.RLock()
_, exists := prl.requests["stale-ip"]
prl.mu.RUnlock()
if exists {
t.Error("expected stale IP to be cleaned up")
}
}
// ============================================================
// RateLimiter Cleanup Tests
// ============================================================
// TestRateLimiter_Cleanup verifies that stale entries are removed.
func TestRateLimiter_Cleanup(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
rl.mu.Lock()
rl.requests["stale-key"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl.requests["fresh-key"] = []time.Time{clock.Now()}
rl.mu.Unlock()
rl.Cleanup()
rl.mu.RLock()
_, staleExists := rl.requests["stale-key"]
_, freshExists := rl.requests["fresh-key"]
rl.mu.RUnlock()
if staleExists {
t.Error("expected stale-key to be removed")
}
if !freshExists {
t.Error("expected fresh-key to be preserved")
}
}
// TestRateLimiter_Cleanup_EmptyMap handles an empty requests map.
func TestRateLimiter_Cleanup_EmptyMap(t *testing.T) {
rl := NewRateLimiter(10, time.Minute)
rl.mu.Lock()
rl.requests = make(map[string][]time.Time)
rl.mu.Unlock()
rl.Cleanup()
rl.mu.RLock()
count := len(rl.requests)
rl.mu.RUnlock()
if count != 0 {
t.Errorf("expected empty map, got %d entries", count)
}
}
// TestCleanupAllRateLimiters iterates all registered limiters.
func TestCleanupAllRateLimiters(t *testing.T) {
registeredLimitersMu.Lock()
saved := registeredLimiters
registeredLimitersMu.Unlock()
defer func() {
registeredLimitersMu.Lock()
registeredLimiters = saved
registeredLimitersMu.Unlock()
}()
rl1 := NewRateLimiter(10, time.Minute)
rl2 := NewRateLimiter(20, time.Minute)
rl1.mu.Lock()
rl1.requests["rl1-stale"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl1.mu.Unlock()
rl2.mu.Lock()
rl2.requests["rl2-stale"] = []time.Time{clock.Now().Add(-5 * time.Minute)}
rl2.mu.Unlock()
_, err := CleanupAllRateLimiters(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
rl1.mu.RLock()
_, rl1Stale := rl1.requests["rl1-stale"]
rl1.mu.RUnlock()
rl2.mu.RLock()
_, rl2Stale := rl2.requests["rl2-stale"]
rl2.mu.RUnlock()
if rl1Stale {
t.Error("expected rl1-stale to be removed")
}
if rl2Stale {
t.Error("expected rl2-stale to be removed")
}
}
// TestCleanupAllRateLimiters_Empty does not panic with no registered limiters.
func TestCleanupAllRateLimiters_Empty(t *testing.T) {
registeredLimitersMu.Lock()
saved := registeredLimiters
registeredLimiters = nil
registeredLimitersMu.Unlock()
defer func() {
registeredLimitersMu.Lock()
registeredLimiters = saved
registeredLimitersMu.Unlock()
}()
_, err := CleanupAllRateLimiters(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
// TestCleanupProgressiveRateLimiter cleans the global limiter.
func TestCleanupProgressiveRateLimiter(t *testing.T) {
globalProgressiveLimiter.mu.Lock()
saved := globalProgressiveLimiter.requests
globalProgressiveLimiter.requests = map[string]*ipProgressiveState{
"global-stale": {timestamps: []time.Time{clock.Now().Add(-120 * time.Second)}},
}
globalProgressiveLimiter.mu.Unlock()
defer func() {
globalProgressiveLimiter.mu.Lock()
globalProgressiveLimiter.requests = saved
globalProgressiveLimiter.mu.Unlock()
}()
_, err := CleanupProgressiveRateLimiter(context.Background())
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
globalProgressiveLimiter.mu.RLock()
_, exists := globalProgressiveLimiter.requests["global-stale"]
globalProgressiveLimiter.mu.RUnlock()
if exists {
t.Error("expected global-stale to be removed")
}
}
// ============================================================
// clientIP — per-IP rate-limit key derivation (batch-1 fix)
// ============================================================
// setTrustProxyHeaders temporarily overrides the package-level
// trustProxyHeaders flag so clientIP()'s priority order can be exercised in
// both states. The flag is an init-time capture of TRUST_PROXY_HEADERS (see
// ratelimit_shared.go) that cannot be re-read after process start; the tests
// live in package mw, so the unexported var is reachable directly.
func setTrustProxyHeaders(t *testing.T, v bool) {
t.Helper()
saved := trustProxyHeaders
trustProxyHeaders = v
t.Cleanup(func() { trustProxyHeaders = saved })
}
// TestClientIP_CFConnectingIP_HonoredWhenTrusted verifies that with
// TRUST_PROXY_HEADERS=true a CF-Connecting-IP header becomes the rate-limit
// key (a trusted edge has already overwritten it with the real client IP).
func TestClientIP_CFConnectingIP_HonoredWhenTrusted(t *testing.T) {
setTrustProxyHeaders(t, true)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
if got := clientIP(req); got != "203.0.113.7" {
t.Errorf("expected trusted CF-Connecting-IP to win, got %q", got)
}
}
// TestClientIP_CFConnectingIP_IgnoredWhenUntrusted verifies the default
// TRUST_PROXY_HEADERS=false behaviour: a client-supplied CF-Connecting-IP is
// ignored so an origin-exposed backend can never let a client forge its own
// rate-limit key.
func TestClientIP_CFConnectingIP_IgnoredWhenUntrusted(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
if got := clientIP(req); got != "192.0.2.1" {
t.Errorf("expected untrusted CF-Connecting-IP to be ignored, got %q", got)
}
}
// TestClientIP_XRealIPContext_WhenMiddlewareRegistered verifies the chi
// ClientIPFromHeader("X-Real-IP") context path: the X-Real-IP value nginx sets
// from $remote_addr is used as the key once the middleware has captured it.
func TestClientIP_XRealIPContext_WhenMiddlewareRegistered(t *testing.T) {
setTrustProxyHeaders(t, true)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "198.51.100.42" {
t.Errorf("expected X-Real-IP from context, got %q", got)
}
}
// TestClientIP_CFWinsOverContext verifies the priority order when both a
// trusted CF-Connecting-IP header and a context client IP are present: the
// CF header is priority 1, the context (X-Real-IP) value priority 2.
func TestClientIP_CFWinsOverContext(t *testing.T) {
setTrustProxyHeaders(t, true)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.9")
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "203.0.113.9" {
t.Errorf("expected CF-Connecting-IP to beat the context value, got %q", got)
}
}
// TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted verifies the context
// value (set by the middleware) is read unconditionally — clientIP does not
// re-check trustProxyHeaders for it — so a spoofed CF header with no trusted
// edge cannot override a middleware-captured value.
func TestClientIP_ContextBeatsRemoteAddrEvenWhenCFUntrusted(t *testing.T) {
setTrustProxyHeaders(t, false)
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("CF-Connecting-IP", "203.0.113.7")
req.Header.Set("X-Real-IP", "198.51.100.42")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "198.51.100.42" {
t.Errorf("expected context client IP to beat RemoteAddr, got %q", got)
}
}
// TestClientIP_FallbackToRemoteAddr verifies the last-resort key source: the
// TCP peer from r.RemoteAddr once the port is split off.
func TestClientIP_FallbackToRemoteAddr(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
if got := clientIP(req); got != "192.0.2.1" {
t.Errorf("expected RemoteAddr host as fallback, got %q", got)
}
}
// TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs verifies that a RemoteAddr
// lacking a port (no SplitHostPort success) is returned verbatim rather than
// being dropped.
func TestClientIP_RemoteAddrWithoutPort_ReturnedAsIs(t *testing.T) {
setTrustProxyHeaders(t, false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.5"
if got := clientIP(req); got != "192.0.2.5" {
t.Errorf("expected portless RemoteAddr to pass through, got %q", got)
}
}
// TestClientIP_XRealIP_GarbageHeaderFallsBackToRemoteAddr verifies the
// defense-in-depth validation (FIX 4): even with TRUST_PROXY_HEADERS=true, an
// X-Real-IP carrying a comma-joined chain ("123.45.67.89, 1.2.3.4"), an
// IP:port, or a non-IP string must NOT become the rate-limit key — it falls
// back to the TCP peer instead. The flag must only be set behind a trusted
// proxy that overwrites the header itself; this validation ensures a mis-set
// flag (or a header-echoing proxy) cannot mint a fresh bucket per request and
// bypass per-IP limiting.
func TestClientIP_XRealIP_GarbageHeaderFallsBackToRemoteAddr(t *testing.T) {
setTrustProxyHeaders(t, true)
for _, tc := range []struct {
name string
header string
}{
{name: "comma_joined_chain", header: "123.45.67.89, 1.2.3.4"},
{name: "ip_with_port", header: "198.51.100.42:8080"},
{name: "garbage_string", header: "not-an-ip"},
} {
t.Run(tc.name, func(t *testing.T) {
var got string
h := middleware.ClientIPFromHeader("X-Real-IP")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = clientIP(r)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("X-Real-IP", tc.header)
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "192.0.2.1" {
t.Errorf("expected garbage X-Real-IP %q to fall back to RemoteAddr 192.0.2.1, got %q", tc.header, got)
}
})
}
}
// TestClientIP_CFConnectingIP_GarbageHeaderFallsBack verifies the same
// defense-in-depth for CF-Connecting-IP: even when trusted, a non-IP value
// (comma-joined chain, port, garbage) must not become the rate-limit key.
func TestClientIP_CFConnectingIP_GarbageHeaderFallsBack(t *testing.T) {
setTrustProxyHeaders(t, true)
for _, tc := range []struct {
name string
header string
}{
{name: "comma_joined_chain", header: "203.0.113.7, 198.51.100.9"},
{name: "ip_with_port", header: "203.0.113.7:8080"},
{name: "garbage_string", header: "spoofed!"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.2:1234"
req.Header.Set("CF-Connecting-IP", tc.header)
if got := clientIP(req); got != "192.0.2.2" {
t.Errorf("expected garbage CF-Connecting-IP %q to fall back to RemoteAddr 192.0.2.2, got %q", tc.header, got)
}
})
}
}
// ============================================================
// RateLimit middleware — 429 on burst, pass-through under limit,
// per-key buckets (batch-1 fix regression)
// ============================================================
// newRateLimitTestHandler builds a RateLimit-wrapped handler that records how
// many times the inner handler was reached.
func newRateLimitTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimit(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
func serveRateLimitRequest(t *testing.T, h http.Handler, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitMiddleware_RequestsUnderLimitPassThrough verifies requests
// within the per-IP limit reach the handler untouched.
func TestRateLimitMiddleware_RequestsUnderLimitPassThrough(t *testing.T) {
handler, calls := newRateLimitTestHandler(2, time.Minute)
for i := 0; i < 2; i++ {
w := serveRateLimitRequest(t, handler, "192.0.2.10:1234")
if w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d (body: %s)", i+1, w.Code, w.Body.String())
}
}
if *calls != 2 {
t.Errorf("expected 2 handler calls, got %d", *calls)
}
}
// TestRateLimitMiddleware_BurstAboveLimitReturns429 verifies the request that
// crosses the limit is answered 429 and the inner handler is never reached.
func TestRateLimitMiddleware_BurstAboveLimitReturns429(t *testing.T) {
handler, calls := newRateLimitTestHandler(2, time.Minute)
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.11:1234"); w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
w := serveRateLimitRequest(t, handler, "192.0.2.11:1234")
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 on the request above the limit, got %d", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "Rate limit exceeded") {
t.Errorf("expected a rate-limit error body, got %q", body)
}
if *calls != 2 {
t.Errorf("expected inner handler to be called exactly twice, got %d", *calls)
}
}
// TestRateLimitMiddleware_PerKeyBuckets verifies different IPs get independent
// buckets: exhausting one IP must not exhaust another.
func TestRateLimitMiddleware_PerKeyBuckets(t *testing.T) {
handler, _ := newRateLimitTestHandler(2, time.Minute)
// Exhaust IP A.
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitRequest(t, handler, "192.0.2.20:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected IP A to be rate-limited after its burst, got %d", w.Code)
}
// IP B is a separate bucket and still has its full allowance.
for i := 0; i < 2; i++ {
if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitRequest(t, handler, "192.0.2.21:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected IP B to be rate-limited only after ITS OWN burst, got %d", w.Code)
}
}
// ============================================================
// RateLimitByUserAndIP — user+IP-keyed middleware (A7 fix:
// per-IP limiter collapsing to a global budget behind a proxy)
// ============================================================
// newRateLimitByUserAndIPTestHandler builds a RateLimitByUserAndIP-wrapped
// handler that records how many times the inner handler was reached.
func newRateLimitByUserAndIPTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimitByUserAndIP(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
// serveRateLimitUserRequest serves a request with an optional authenticated
// userID in context (the equivalent of RequireAuth having run) through h.
func serveRateLimitUserRequest(t *testing.T, h http.Handler, userID, remoteAddr string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
if userID != "" {
req = req.WithContext(context.WithValue(req.Context(), UserIDKey, userID))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP verifies the keyed
// limiter's core guarantee: two different users behind the SAME proxy IP (the
// TRUST_PROXY_HEADERS=false nginx scenario, where clientIP returns the proxy's
// address for everyone) get INDEPENDENT budgets — one user exhausting their
// 10/min allowance can never 429 the other user's 2FA setup/verify/disable.
func TestRateLimitByUserAndIP_PerUserBucketsBehindSameIP(t *testing.T) {
handler, calls := newRateLimitByUserAndIPTestHandler(2, time.Minute)
// User A exhausts its own budget from the shared proxy IP.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-a", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user A to be rate-limited after its own burst, got %d", w.Code)
}
// User B shares the proxy IP but must keep its own full allowance.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent user bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-b", "10.0.0.5:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user B to be limited only after ITS OWN burst, got %d", w.Code)
}
if *calls != 4 {
t.Errorf("expected exactly 4 handler calls (2 per user), got %d", *calls)
}
}
// TestRateLimitByUserAndIP_SameUserSharesOneBudget verifies the same user's
// requests across all four 2FA routes count against one shared budget (the
// "one shared limiter for all four routes" contract).
func TestRateLimitByUserAndIP_SameUserSharesOneBudget(t *testing.T) {
handler, _ := newRateLimitByUserAndIPTestHandler(3, time.Minute)
for i := 0; i < 3; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusOK {
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-c", "198.51.100.15:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected the 4th request from the same user to be limited, got %d", w.Code)
}
}
// TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP verifies the fallback:
// without a userID in context the key is the client IP alone (same behaviour
// as RateLimit), so the middleware stays safe on unauthenticated paths.
func TestRateLimitByUserAndIP_UnauthenticatedFallsBackToIP(t *testing.T) {
handler, _ := newRateLimitByUserAndIPTestHandler(1, time.Minute)
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusOK {
t.Fatalf("expected 200 for the first request, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.20:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected a second unauthenticated request from the same IP to be limited, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.21:1234"); w.Code != http.StatusOK {
t.Errorf("expected a different IP to keep its own bucket, got %d", w.Code)
}
}
// ============================================================
// RateLimitByUser — user-keyed middleware (B8: the 2FA + gift-card
// redeem budgets must survive IP rotation / header spoofing)
// ============================================================
// newRateLimitByUserTestHandler builds a RateLimitByUser-wrapped handler that
// records how many times the inner handler was reached.
func newRateLimitByUserTestHandler(limit int, window time.Duration) (http.Handler, *int) {
calls := 0
handler := RateLimitByUser(limit, window)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
return handler, &calls
}
// TestRateLimitByUser_OneBudgetPerUserRegardlessOfIP verifies the B8 fix: the
// 2FA/gift-card-redeem budget keys on the userID ALONE, so an attacker who
// rotates the client IP (or spoofs CF-Connecting-IP behind a misconfigured
// TRUST_PROXY_HEADERS=true proxy) cannot mint a fresh bucket per IP for the
// same account. One user exhausting their budget is limited even from a brand
// new IP, while a DIFFERENT user keeps an independent budget.
func TestRateLimitByUser_OneBudgetPerUserRegardlessOfIP(t *testing.T) {
handler, calls := newRateLimitByUserTestHandler(2, time.Minute)
// User A exhausts its 2/min budget from IP1...
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-a", "198.51.100.1:1234"); w.Code != http.StatusOK {
t.Fatalf("A request %d: expected 200, got %d", i+1, w.Code)
}
}
// ...and is STILL limited from IP2 — the IP component is not part of the
// key, so rotating it cannot mint a fresh bucket (the B8 bypass).
if w := serveRateLimitUserRequest(t, handler, "user-a", "198.51.100.2:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user A to stay limited after rotating IP, got %d", w.Code)
}
// A different user keeps its own full allowance even from the SAME IPs.
for i := 0; i < 2; i++ {
if w := serveRateLimitUserRequest(t, handler, "user-b", "198.51.100.1:1234"); w.Code != http.StatusOK {
t.Fatalf("B request %d: expected 200 (independent user bucket), got %d", i+1, w.Code)
}
}
if w := serveRateLimitUserRequest(t, handler, "user-b", "198.51.100.2:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected user B to be limited only after ITS OWN burst, got %d", w.Code)
}
if *calls != 4 {
t.Errorf("expected exactly 4 handler calls (2 per user), got %d", *calls)
}
}
// TestRateLimitByUser_UnauthenticatedFallsBackToIP verifies the fallback: with
// no userID in context the key is the client IP alone, so the middleware stays
// safe on unauthenticated paths.
func TestRateLimitByUser_UnauthenticatedFallsBackToIP(t *testing.T) {
handler, _ := newRateLimitByUserTestHandler(1, time.Minute)
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.30:1234"); w.Code != http.StatusOK {
t.Fatalf("expected 200 for the first request, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.30:1234"); w.Code != http.StatusTooManyRequests {
t.Errorf("expected a second unauthenticated request from the same IP to be limited, got %d", w.Code)
}
if w := serveRateLimitUserRequest(t, handler, "", "198.51.100.31:1234"); w.Code != http.StatusOK {
t.Errorf("expected a different IP to keep its own bucket, got %d", w.Code)
}
}
// ============================================================
// ProgressiveRateLimiter.Check — dual-window progressive delay
// algorithm (batch-1 fix regression)
// ============================================================
// seedProgressiveTimestamps arms prl.requests[ip] so the NEXT Check() call
// observes exactly `burst` timestamps inside the 5s window and `sustained`
// inside the 60s window. Check() appends its own timestamp first, so one fewer
// is seeded: `burst-1` recent timestamps (now-1s, inside the burst window) and
// `sustained-burst` older-but-in-window timestamps (now-10s, outside the 5s
// burst window but inside the 60s sustained window).
func seedProgressiveTimestamps(t *testing.T, prl *ProgressiveRateLimiter, ip string, burst, sustained int) {
t.Helper()
if sustained < burst {
t.Fatalf("sustained (%d) must be >= burst (%d)", sustained, burst)
}
now := clock.Now()
ts := make([]time.Time, 0, sustained-1)
for i := 0; i < burst-1; i++ {
ts = append(ts, now.Add(-time.Second))
}
for i := 0; i < sustained-burst; i++ {
ts = append(ts, now.Add(-10*time.Second))
}
prl.mu.Lock()
prl.requests[ip] = &ipProgressiveState{timestamps: ts}
prl.mu.Unlock()
}
// TestProgressiveRateLimiter_CheckDelayTiers pins the exact algorithm: no
// delay while burst <= 30 AND sustained <= 120; then delay escalates with the
// sustained rate (500ms / 2s / 5s / 10s tiers).
func TestProgressiveRateLimiter_CheckDelayTiers(t *testing.T) {
cases := []struct {
name string
burst int
sustained int
wantMs int
}{
{"under both thresholds is free", 30, 120, 0},
{"burst alone trips the lowest tier", 31, 120, 500},
{"sustained alone trips the lowest tier", 30, 121, 500},
{"500ms tier ceiling", 31, 140, 500},
{"2s tier floor", 31, 141, 2000},
{"2s tier ceiling", 31, 200, 2000},
{"5s tier floor", 31, 201, 5000},
{"5s tier ceiling", 31, 300, 5000},
{"10s abuse tier", 31, 301, 10000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "198.51.100.7"
seedProgressiveTimestamps(t, prl, ip, tc.burst, tc.sustained)
if got := prl.Check(ip); got != tc.wantMs {
t.Errorf("burst=%d sustained=%d: expected %dms delay, got %dms", tc.burst, tc.sustained, tc.wantMs, got)
}
})
}
}
// TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate verifies the
// delay increases as a sustained high rate climbs through the tiers on
// repeated hits.
func TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "203.0.113.42"
// Start already over the burst limit so every check is throttled.
seedProgressiveTimestamps(t, prl, ip, 31, 31)
steps := []struct {
extraSustained int
wantMs int
}{
{0, 500}, // observed sustained=31 → 500ms tier
{109, 2000}, // observed sustained=141 → 2s tier
{60, 5000}, // observed sustained=202 → 5s tier
{100, 10000}, // observed sustained=303 → 10s abuse tier
}
for i, s := range steps {
if s.extraSustained > 0 {
prl.mu.Lock()
state := prl.requests[ip]
for j := 0; j < s.extraSustained; j++ {
state.timestamps = append(state.timestamps, clock.Now().Add(-6*time.Second))
}
prl.mu.Unlock()
}
if got := prl.Check(ip); got != s.wantMs {
t.Errorf("step %d: expected %dms delay, got %dms", i, s.wantMs, got)
}
}
}
// ============================================================
// TRUST_PROXY_HEADERS mode switching on the per-IP and per-user limiters (M24-prep)
// ============================================================
// serveRateLimitRequestWithCF serves a request with an explicit RemoteAddr and
// a client-supplied CF-Connecting-IP header, returning the recorder.
func serveRateLimitRequestWithCF(t *testing.T, h http.Handler, remoteAddr, cfHeader string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
req.Header.Set("CF-Connecting-IP", cfHeader)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// serveRateLimitUserRequestWithCF serves a RateLimitByUserAndIP/BYUser-wrapped
// request with an optional authenticated userID, an explicit RemoteAddr, and a
// client-supplied CF-Connecting-IP header.
func serveRateLimitUserRequestWithCF(t *testing.T, h http.Handler, userID, remoteAddr, cfHeader string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = remoteAddr
req.Header.Set("CF-Connecting-IP", cfHeader)
if userID != "" {
req = req.WithContext(context.WithValue(req.Context(), UserIDKey, userID))
}
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
// TestRateLimitMiddleware_TrustedHeaderBecomesKey verifies TRUST_PROXY_HEADERS=true
// on the per-IP limiter: the rate-limit key is the trusted CF-Connecting-IP
// header (the proxy overwrote it with the real client IP), so two clients behind
// the SAME proxy address get independent buckets — without this, every client
// collapses onto the proxy's RemoteAddr and one client could exhaust the shared
// per-IP budget for everyone. The key is the header, not the proxy address: the
// same client stays exhausted across proxy addresses.
func TestRateLimitMiddleware_TrustedHeaderBecomesKey(t *testing.T) {
setTrustProxyHeaders(t, true)
handler, calls := newRateLimitTestHandler(1, time.Minute)
if w := serveRateLimitRequestWithCF(t, handler, "10.0.0.1:1234", "198.51.100.10"); w.Code != http.StatusOK {
t.Fatalf("client A first request: expected 200, got %d", w.Code)
}
if w := serveRateLimitRequestWithCF(t, handler, "10.0.0.1:1234", "198.51.100.10"); w.Code != http.StatusTooManyRequests {
t.Errorf("client A second request: expected 429 (own bucket exhausted), got %d", w.Code)
}
if w := serveRateLimitRequestWithCF(t, handler, "10.0.0.1:1234", "198.51.100.11"); w.Code != http.StatusOK {
t.Errorf("client B behind the same proxy must keep an independent bucket, got %d", w.Code)
}
// The same client from a different proxy address shares ONE bucket — the
// trusted header, not the proxy address, is the key.
if w := serveRateLimitRequestWithCF(t, handler, "10.0.0.2:1234", "198.51.100.10"); w.Code != http.StatusTooManyRequests {
t.Errorf("client A must stay exhausted across proxy addresses, got %d", w.Code)
}
if *calls != 2 {
t.Errorf("expected exactly 2 handler calls (A and B first hits), got %d", *calls)
}
}
// TestRateLimitMiddleware_UntrustedHeaderIgnored verifies the origin-exposed
// default (TRUST_PROXY_HEADERS=false) on the per-IP limiter: a client-supplied
// CF-Connecting-IP must NOT become the rate-limit key, or an origin-exposed
// client could rotate the header to mint a fresh bucket per request and bypass
// per-IP rate limiting. The key is the real TCP peer (RemoteAddr).
func TestRateLimitMiddleware_UntrustedHeaderIgnored(t *testing.T) {
setTrustProxyHeaders(t, false)
handler, calls := newRateLimitTestHandler(1, time.Minute)
if w := serveRateLimitRequestWithCF(t, handler, "192.0.2.50:1234", "203.0.113.99"); w.Code != http.StatusOK {
t.Fatalf("first request: expected 200, got %d", w.Code)
}
if w := serveRateLimitRequestWithCF(t, handler, "192.0.2.50:1234", "203.0.113.100"); w.Code != http.StatusTooManyRequests {
t.Errorf("rotating the spoofed header must NOT mint a fresh bucket, got %d", w.Code)
}
if w := serveRateLimitRequestWithCF(t, handler, "192.0.2.51:1234", "203.0.113.99"); w.Code != http.StatusOK {
t.Errorf("a genuinely different peer must keep an independent bucket, got %d", w.Code)
}
if *calls != 2 {
t.Errorf("expected exactly 2 handler calls, got %d", *calls)
}
}
// TestRateLimitByUserAndIP_TrustedHeaderKeysIPComponent verifies the per-user
// limiter resolves the IP component through the SAME gated source as the
// per-IP limiter when TRUST_PROXY_HEADERS=true: the key is (user, trusted
// CF-Connecting-IP), so one user behind the shared proxy keeps one budget
// across proxy addresses and a different user gets an independent one.
func TestRateLimitByUserAndIP_TrustedHeaderKeysIPComponent(t *testing.T) {
setTrustProxyHeaders(t, true)
handler, calls := newRateLimitByUserAndIPTestHandler(1, time.Minute)
if w := serveRateLimitUserRequestWithCF(t, handler, "user-a", "10.0.0.9:1234", "198.51.100.20"); w.Code != http.StatusOK {
t.Fatalf("user A first request: expected 200, got %d", w.Code)
}
// user-a again from a DIFFERENT proxy address but the SAME trusted header:
// the (user, header) key keeps the budget exhausted.
if w := serveRateLimitUserRequestWithCF(t, handler, "user-a", "10.0.0.10:1234", "198.51.100.20"); w.Code != http.StatusTooManyRequests {
t.Errorf("user A must stay exhausted across proxy addresses (header is the IP key), got %d", w.Code)
}
// user-b behind the same proxy address with its own header: independent.
if w := serveRateLimitUserRequestWithCF(t, handler, "user-b", "10.0.0.9:1234", "198.51.100.21"); w.Code != http.StatusOK {
t.Errorf("user B must keep an independent budget, got %d", w.Code)
}
if *calls != 2 {
t.Errorf("expected exactly 2 handler calls (A and B first hits), got %d", *calls)
}
}
// TestRateLimitByUserAndIP_UntrustedHeaderIgnored verifies the per-user limiter
// ignores a spoofed CF-Connecting-IP when TRUST_PROXY_HEADERS=false: the IP
// component is the real TCP peer, so the same user rotating the spoofed header
// cannot mint a fresh (user, IP) budget per request.
func TestRateLimitByUserAndIP_UntrustedHeaderIgnored(t *testing.T) {
setTrustProxyHeaders(t, false)
handler, calls := newRateLimitByUserAndIPTestHandler(1, time.Minute)
if w := serveRateLimitUserRequestWithCF(t, handler, "user-a", "192.0.2.60:1234", "203.0.113.201"); w.Code != http.StatusOK {
t.Fatalf("user A first request: expected 200, got %d", w.Code)
}
if w := serveRateLimitUserRequestWithCF(t, handler, "user-a", "192.0.2.60:1234", "203.0.113.202"); w.Code != http.StatusTooManyRequests {
t.Errorf("rotating the spoofed header must NOT mint a fresh (user,IP) bucket, got %d", w.Code)
}
if w := serveRateLimitUserRequestWithCF(t, handler, "user-b", "192.0.2.60:1234", "203.0.113.201"); w.Code != http.StatusOK {
t.Errorf("user B from the same peer must keep an independent budget, got %d", w.Code)
}
if *calls != 2 {
t.Errorf("expected exactly 2 handler calls, got %d", *calls)
}
}
// TestRateLimitByUser_UnauthenticatedFallback_HonorsHeaderMode verifies the
// B8 user-keyed limiter's UNAUTHENTICATED fallback (key = ClientIP) honors the
// same TRUST_PROXY_HEADERS switching as the per-IP limiter: trusted mode keys
// the fallback on the header, untrusted mode on the RemoteAddr — an
// origin-exposed client cannot rotate the spoofed header to bypass the
// unauthenticated budget.
func TestRateLimitByUser_UnauthenticatedFallback_HonorsHeaderMode(t *testing.T) {
t.Run("trusted_headers_key_on_cf_header", func(t *testing.T) {
setTrustProxyHeaders(t, true)
handler, calls := newRateLimitByUserTestHandler(1, time.Minute)
if w := serveRateLimitUserRequestWithCF(t, handler, "", "10.0.0.1:1234", "198.51.100.30"); w.Code != http.StatusOK {
t.Fatalf("first request: expected 200, got %d", w.Code)
}
if w := serveRateLimitUserRequestWithCF(t, handler, "", "10.0.0.1:1234", "198.51.100.30"); w.Code != http.StatusTooManyRequests {
t.Errorf("same trusted header must stay exhausted, got %d", w.Code)
}
if w := serveRateLimitUserRequestWithCF(t, handler, "", "10.0.0.1:1234", "198.51.100.31"); w.Code != http.StatusOK {
t.Errorf("a distinct trusted header must keep an independent bucket, got %d", w.Code)
}
if *calls != 2 {
t.Errorf("expected exactly 2 handler calls, got %d", *calls)
}
})
t.Run("untrusted_headers_key_on_remoteaddr", func(t *testing.T) {
setTrustProxyHeaders(t, false)
handler, calls := newRateLimitByUserTestHandler(1, time.Minute)
if w := serveRateLimitUserRequestWithCF(t, handler, "", "192.0.2.70:1234", "203.0.113.210"); w.Code != http.StatusOK {
t.Fatalf("first request: expected 200, got %d", w.Code)
}
if w := serveRateLimitUserRequestWithCF(t, handler, "", "192.0.2.70:1234", "203.0.113.211"); w.Code != http.StatusTooManyRequests {
t.Errorf("rotating the spoofed header must NOT mint a fresh bucket, got %d", w.Code)
}
if w := serveRateLimitUserRequestWithCF(t, handler, "", "192.0.2.71:1234", "203.0.113.210"); w.Code != http.StatusOK {
t.Errorf("a genuinely different peer must keep an independent bucket, got %d", w.Code)
}
if *calls != 2 {
t.Errorf("expected exactly 2 handler calls, got %d", *calls)
}
})
}
// finding 5: ONLY the top 10s abuse tier rejects the request 429 immediately
// instead of sleeping a goroutine (a per-client goroutine-parking amplifier in
// front of bcrypt); the 500ms / 2s / 5s tiers keep sleeping (backoff). Before
// finding 5 every tier past 2s hard-rejected, which under a shared NAT /
// TRUST_PROXY_HEADERS=false deployment locked out the whole surface behind one
// abusive client.
func TestProgressiveRateLimit_RejectsOnlyTopTier(t *testing.T) {
globalProgressiveLimiter.mu.Lock()
saved := globalProgressiveLimiter.requests
globalProgressiveLimiter.requests = make(map[string]*ipProgressiveState)
globalProgressiveLimiter.mu.Unlock()
t.Cleanup(func() {
globalProgressiveLimiter.mu.Lock()
globalProgressiveLimiter.requests = saved
globalProgressiveLimiter.mu.Unlock()
})
// 5s tier (sustained 201-300): sleeps (backoff), never rejects — the NAT /
// proxy-collapse case where a moderate sustained rate must not hard-lock
// the whole shared surface.
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.98", 31, 250)
nextCalled := false
handler := ProgressiveRateLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "198.51.100.98:1234"
w := httptest.NewRecorder()
start := time.Now()
handler.ServeHTTP(w, req)
if !nextCalled {
t.Error("the 5s tier must sleep (backoff), not reject — next handler must be called")
}
if w.Code != http.StatusOK {
t.Errorf("expected the 5s tier to sleep and pass through, got %d", w.Code)
}
if got := w.Header().Get("X-RateLimit-Delay"); got != "5000" {
t.Errorf("expected X-RateLimit-Delay=5000 for the 5s tier, got %q", got)
}
if elapsed := time.Since(start); elapsed < 4*time.Second {
t.Errorf("the 5s tier must actually sleep (took %s)", elapsed)
}
// 10s abuse tier (sustained > 300): rejects 429 immediately.
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.99", 31, 320)
nextCalled = false
req = httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "198.51.100.99:1234"
w = httptest.NewRecorder()
start = time.Now()
handler.ServeHTTP(w, req)
if nextCalled {
t.Error("next handler must NOT be called at the 10s abuse tier")
}
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429 at the 10s abuse tier, got %d", w.Code)
}
if elapsed := time.Since(start); elapsed >= 5*time.Second {
t.Errorf("the 10s tier must reject immediately, not sleep (took %s)", elapsed)
}
}