Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed: MONEY: - CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) — a rejected auto-refund no longer re-replays the expired key every sweep run (which minted a stacking unauthorized charge each time); FAILED-webhook demotion respects the cap; never re-replay a key whose B1 refund failed - HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible campaign discount rows immediately (capped) instead of skipping with no discount recorded — no more promised-discount-not-recorded overcharge - MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed new-card+save_card charges (re-issue guard now covers req.SaveCard) - LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining now matches the authoritative tip-excluded balance) SECURITY: - MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap on critical_payment_log + refresh_token_reuse rows) - MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer clears LastMintAt on gate-verify; cleared on terminal charge success) - MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked state instead of a fresh 5-guess budget per request - MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs instead of sleeping unboundedly; login bcrypt concurrency semaphore added - LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check; email-verification per-user attempt counter DUP/MOD: - formatCurrency single source (frontend format.ts, 7 files consolidated); SquareRefundStatusToLocal single source (errors.go, all sites); admin audit-log helper dedup; SCA retry model unified (proactive on all 6 surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from backend; generateUUID at all card-form sites; magic numbers named (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card terminal charges now audited; DAV_SKIP_INIT documented in manuals Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the CI condition), both vet tags, frontend tests+build, env-docs 42/42.
696 lines
25 KiB
Go
696 lines
25 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)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestProgressiveRateLimit_RejectsBeyondSleepCap pins finding 4a: once the
|
|
// computed delay exceeds maxProgressiveSleepDelayMs (2s), the middleware
|
|
// rejects the request 429 immediately instead of sleeping a goroutine for
|
|
// 5-10s (a per-client goroutine-parking amplifier in front of bcrypt). The
|
|
// small progressive tiers (500ms / 2s) still sleep.
|
|
func TestProgressiveRateLimit_RejectsBeyondSleepCap(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()
|
|
})
|
|
|
|
// Seed the 10s abuse tier (sustained > 300) for this IP.
|
|
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.99", 31, 320)
|
|
|
|
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.99:1234"
|
|
w := httptest.NewRecorder()
|
|
start := time.Now()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if nextCalled {
|
|
t.Error("next handler must NOT be called when the delay exceeds the sleep cap")
|
|
}
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Errorf("expected 429, got %d", w.Code)
|
|
}
|
|
if elapsed := time.Since(start); elapsed >= 5*time.Second {
|
|
t.Errorf("the 5-10s tiers must reject immediately, not sleep (took %s)", elapsed)
|
|
}
|
|
}
|