Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1030 lines
40 KiB
Go
1030 lines
40 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)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ProgressiveRateLimiter pruning: timestamps pruned before counting
|
|
// ============================================================
|
|
|
|
// TestProgressiveRateLimiter_TimestampsBoundedAfterManyCalls verifies that
|
|
// calling Check() many times does not cause unbounded growth of the timestamps
|
|
// slice. The pruning step in Check() should keep the slice bounded to at most
|
|
// the number of timestamps that fit within the 60-second window.
|
|
func TestProgressiveRateLimiter_TimestampsBoundedAfterManyCalls(t *testing.T) {
|
|
prl := NewProgressiveRateLimiter()
|
|
ip := "198.51.100.77"
|
|
|
|
// Make 100 rapid calls — each appends a timestamp, then the pruning step
|
|
// removes anything older than 60s. Since all 100 calls happen within much
|
|
// less than 60s, the slice should contain at most 100 entries after pruning.
|
|
for i := 0; i < 100; i++ {
|
|
_ = prl.Check(ip)
|
|
}
|
|
|
|
prl.mu.RLock()
|
|
state, exists := prl.requests[ip]
|
|
prl.mu.RUnlock()
|
|
if !exists {
|
|
t.Fatal("expected ip state to exist after 100 calls")
|
|
}
|
|
if len(state.timestamps) > 100 {
|
|
t.Errorf("timestamps unbounded after 100 calls: got %d entries", len(state.timestamps))
|
|
}
|
|
|
|
// The slice should not have more than 120 entries (the sustained limit)
|
|
// after rapid calling — the pruning in Check() keeps it bounded.
|
|
maxExpected := 120
|
|
if len(state.timestamps) > maxExpected {
|
|
t.Errorf("expected at most %d timestamps after pruning, got %d", maxExpected, len(state.timestamps))
|
|
}
|
|
}
|
|
|
|
// TestProgressiveRateLimiter_OldTimestampsPrunedOnCheck verifies that
|
|
// timestamps older than 60 seconds are pruned when Check() runs. This is the
|
|
// core of the fix: the pruning happens BEFORE counting, so stale timestamps
|
|
// from burst traffic cannot inflate the sustained count.
|
|
func TestProgressiveRateLimiter_OldTimestampsPrunedOnCheck(t *testing.T) {
|
|
prl := NewProgressiveRateLimiter()
|
|
ip := "198.51.100.78"
|
|
|
|
// Seed timestamps: 10 recent (within 5s) + 100 old (65-120s ago).
|
|
// After pruning, only the 10 recent timestamps should remain.
|
|
now := clock.Now()
|
|
ts := make([]time.Time, 0, 110)
|
|
for i := 0; i < 10; i++ {
|
|
ts = append(ts, now.Add(-time.Second))
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
ts = append(ts, now.Add(-time.Duration(65+i)*time.Second))
|
|
}
|
|
prl.mu.Lock()
|
|
prl.requests[ip] = &ipProgressiveState{timestamps: ts}
|
|
prl.mu.Unlock()
|
|
|
|
// Check() should prune timestamps before counting.
|
|
_ = prl.Check(ip)
|
|
|
|
prl.mu.RLock()
|
|
state := prl.requests[ip]
|
|
count := len(state.timestamps)
|
|
prl.mu.RUnlock()
|
|
|
|
// After pruning, we should have at most 11 entries (10 recent + the one
|
|
// just appended by Check()). All 100 old timestamps should be gone.
|
|
if count > 20 {
|
|
t.Errorf("expected old timestamps to be pruned — got %d entries, expected <= 11", count)
|
|
}
|
|
if count < 5 {
|
|
t.Errorf("expected recent timestamps to survive pruning — got only %d entries", count)
|
|
}
|
|
}
|