diff --git a/backend/mw/ratelimit.go b/backend/mw/ratelimit.go index 99ecd26..a8d6c50 100644 --- a/backend/mw/ratelimit.go +++ b/backend/mw/ratelimit.go @@ -4,6 +4,7 @@ package mw import ( + "fmt" "net/http" "sync" "net" @@ -75,6 +76,126 @@ func (rl *RateLimiter) Allow(key string) bool { return true } +// ProgressiveRateLimiter implements per-IP rate limiting with increasing backoff +// Designed for bot-spam prevention across accounts (not account-specific lockout) +type ProgressiveRateLimiter struct { + requests map[string]*ipProgressiveState + mu sync.RWMutex +} + +type ipProgressiveState struct { + // Timestamps of all requests within the tracking window + timestamps []time.Time +} + +func NewProgressiveRateLimiter() *ProgressiveRateLimiter { + prl := &ProgressiveRateLimiter{ + requests: make(map[string]*ipProgressiveState), + } + go func() { + for { + time.Sleep(30 * time.Second) + prl.cleanup() + } + }() + return prl +} + +func (prl *ProgressiveRateLimiter) cleanup() { + prl.mu.Lock() + defer prl.mu.Unlock() + cutoff := time.Now().Add(-60 * time.Second) + for ip, state := range prl.requests { + var valid []time.Time + for _, t := range state.timestamps { + if t.After(cutoff) { + valid = append(valid, t) + } + } + if len(valid) == 0 { + delete(prl.requests, ip) + } else { + state.timestamps = valid + } + } +} + +// Check returns the delay in milliseconds. Returns 0 if no delay needed. +// Strategy: +// - Count requests in last 5 seconds (burst): allow up to 30 +// - Count requests in last 60 seconds (sustained): allow up to 60 +// - Only delay when BOTH windows are exceeded (high sustained rate with recent bursts) +// - Progressive: once throttled, delay increases with sustained rate +func (prl *ProgressiveRateLimiter) Check(ip string) (delayMs int) { + prl.mu.Lock() + defer prl.mu.Unlock() + + now := time.Now() + state, exists := prl.requests[ip] + if !exists { + prl.requests[ip] = &ipProgressiveState{ + timestamps: []time.Time{now}, + } + return 0 + } + + state.timestamps = append(state.timestamps, now) + + burstCutoff := now.Add(-5 * time.Second) + burstCount := 0 + for _, t := range state.timestamps { + if t.After(burstCutoff) { + burstCount++ + } + } + + sustainedCutoff := now.Add(-60 * time.Second) + sustainedCount := 0 + for _, t := range state.timestamps { + if t.After(sustainedCutoff) { + sustainedCount++ + } + } + + if burstCount <= 30 && sustainedCount <= 120 { + return 0 + } + + // Progressive delay based on how far over the sustained limit they are + // Rate = requests per minute + if sustainedCount <= 140 { + return 500 // 500ms - scraping but not too aggressively + } else if sustainedCount <= 200 { + return 2000 // 2s - moderate spam + } else if sustainedCount <= 300 { + return 5000 // 5s - heavy spam + } else { + return 10000 // 10s - abuse + } +} + +var globalProgressiveLimiter = NewProgressiveRateLimiter() + +func ProgressiveRateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.Header.Get("CF-Connecting-IP") + if ip == "" { + ip, _, _ = net.SplitHostPort(r.RemoteAddr) + if ip == "" { + ip = r.RemoteAddr + } + } + + delay := globalProgressiveLimiter.Check(ip) + if delay > 0 { + time.Sleep(time.Duration(delay) * time.Millisecond) + w.Header().Set("X-RateLimit-Delay", fmt.Sprintf("%d", delay)) + } + + next.ServeHTTP(w, r) + }) +} + // RateLimit middleware - limits requests per IP func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler { limiter := NewRateLimiter(limit, window) diff --git a/backend/mw/ratelimit_dev.go b/backend/mw/ratelimit_dev.go index 48417b4..3efc47f 100644 --- a/backend/mw/ratelimit_dev.go +++ b/backend/mw/ratelimit_dev.go @@ -5,6 +5,7 @@ package mw import ( "net/http" + "sync" "time" ) @@ -15,3 +16,29 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler }) } } + +type ProgressiveRateLimiter struct { + requests map[string]*ipProgressiveState + mu sync.RWMutex +} + +type ipProgressiveState struct { + timestamps []time.Time +} + +func NewProgressiveRateLimiter() *ProgressiveRateLimiter { + return &ProgressiveRateLimiter{ + requests: make(map[string]*ipProgressiveState), + } +} + +func (prl *ProgressiveRateLimiter) cleanup() {} +func (prl *ProgressiveRateLimiter) Check(ip string) int { return 0 } + +var globalProgressiveLimiter = NewProgressiveRateLimiter() + +func ProgressiveRateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) +} diff --git a/backend/mw/ratelimit_test.go b/backend/mw/ratelimit_test.go new file mode 100644 index 0000000..259a47a --- /dev/null +++ b/backend/mw/ratelimit_test.go @@ -0,0 +1,135 @@ +//go:build test && !dev +// +build test,!dev + +package mw + +import ( + "sync" + "testing" + "time" +) + +// TestProgressiveRateLimiter_SingleRequest verifies no delay for first request. +func TestProgressiveRateLimiter_SingleRequest(t *testing.T) { + prl := NewProgressiveRateLimiter() + delay := prl.Check("192.168.1.1") + if delay != 0 { + t.Errorf("expected 0 delay for first request, got %d", delay) + } +} + +// TestProgressiveRateLimiter_BurstAllowsPageLoad verifies 15 requests in 5s are OK. +func TestProgressiveRateLimiter_BurstAllowsPageLoad(t *testing.T) { + prl := NewProgressiveRateLimiter() + ip := "192.168.1.1" + + for i := 0; i < 15; i++ { + delay := prl.Check(ip) + if delay != 0 { + t.Errorf("expected 0 delay for request %d (within burst), got %d", i+1, delay) + } + } +} + +// TestProgressiveRateLimiter_ExcessBurstDelays verifies 35+ requests in 5s get delayed. +func TestProgressiveRateLimiter_ExcessBurstDelays(t *testing.T) { + prl := NewProgressiveRateLimiter() + ip := "192.168.1.1" + + delayed := false + for i := 0; i < 35; i++ { + delay := prl.Check(ip) + if delay > 0 { + delayed = true + } + } + + if !delayed { + t.Error("expected at least one delay after 35 burst requests") + } +} + +// TestProgressiveRateLimiter_SustainedAllowsNormal verifies 30 requests +// spread over 60 seconds are not delayed. +func TestProgressiveRateLimiter_SustainedAllowsNormal(t *testing.T) { + prl := NewProgressiveRateLimiter() + ip := "192.168.1.2" + + for i := 0; i < 30; i++ { + prl.mu.Lock() + state, exists := prl.requests[ip] + if !exists { + prl.requests[ip] = &ipProgressiveState{ + timestamps: []time.Time{time.Now().Add(-time.Duration(60-i*2) * time.Second)}, + } + prl.mu.Unlock() + continue + } + state.timestamps = append(state.timestamps, time.Now().Add(-time.Duration(60-i*2)*time.Second)) + prl.mu.Unlock() + } + + delay := prl.Check(ip) + if delay != 0 { + t.Errorf("expected 0 delay for 30 sustained requests over 60s, got %d", delay) + } +} + +// TestProgressiveRateLimiter_ExcessSustainedDelays verifies 130+ requests +// in 60s triggers delay. +func TestProgressiveRateLimiter_ExcessSustainedDelays(t *testing.T) { + prl := NewProgressiveRateLimiter() + ip := "192.168.1.3" + + now := time.Now() + prl.mu.Lock() + state := &ipProgressiveState{timestamps: make([]time.Time, 130)} + for i := 0; i < 130; i++ { + state.timestamps[i] = now.Add(-time.Duration(60-i/3) * time.Second) + } + prl.requests[ip] = state + prl.mu.Unlock() + + delay := prl.Check(ip) + if delay == 0 { + t.Error("expected delay > 0 for 130 sustained requests") + } +} + +// TestProgressiveRateLimiter_DifferentIPs verifies rate limiter +// tracks IPs independently. +func TestProgressiveRateLimiter_DifferentIPs(t *testing.T) { + prl := NewProgressiveRateLimiter() + + for i := 0; i < 40; i++ { + prl.Check("10.0.0.1") + } + + delay := prl.Check("10.0.0.2") + if delay != 0 { + t.Errorf("expected 0 delay for separate IP, got %d", delay) + } +} + +// 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{time.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") + } +} + +var _ = sync.Mutex{}