Pulse/internal/api/ratelimit.go
rcourtman e30757720a Fix P1: Resource leaks in Recovery Tokens, Rate Limiter, and OIDC Service
Fixed three P1 goroutine/memory leaks that prevent proper resource cleanup:

1. Recovery Tokens goroutine leak
   - Cleanup routine runs forever without stop mechanism
   - Added stopCleanup channel and Stop() method
   - Cleanup loop now uses select with stopCleanup case

2. Rate Limiter goroutine leak
   - Cleanup routine runs forever without stop mechanism
   - Added stopCleanup channel and Stop() method
   - Changed from 'for range ticker.C' to select with stopCleanup case

3. OIDC Service memory leak (DoS vector)
   - Abandoned OIDC flows never cleaned up
   - State entries accumulate unboundedly
   - Added cleanup routine with 5-minute ticker
   - Periodically removes expired state entries (10min TTL)
   - Added Stop() method for proper shutdown

All three follow consistent pattern:
- Add stopCleanup chan struct{} field
- Initialize in constructor
- Use select with ticker and stopCleanup cases
- Close channel in Stop() method to signal goroutine exit

Impact:
- Prevents goroutine leaks during service restarts/reloads
- Prevents memory exhaustion from abandoned OIDC login attempts
- Enables proper cleanup in tests and graceful shutdown
2025-11-07 10:18:44 +00:00

116 lines
2.2 KiB
Go

package api
import (
"net/http"
"sync"
"time"
)
type RateLimiter struct {
attempts map[string][]time.Time
mu sync.RWMutex
limit int
window time.Duration
stopCleanup chan struct{}
}
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
attempts: make(map[string][]time.Time),
limit: limit,
window: window,
stopCleanup: make(chan struct{}),
}
// Clean up old entries periodically
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rl.cleanup()
case <-rl.stopCleanup:
return
}
}
}()
return rl
}
// Stop stops the cleanup routine
func (rl *RateLimiter) Stop() {
close(rl.stopCleanup)
}
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Get attempts for this IP
attempts := rl.attempts[ip]
// Filter out old attempts
var validAttempts []time.Time
for _, attempt := range attempts {
if attempt.After(cutoff) {
validAttempts = append(validAttempts, attempt)
}
}
// Check if under limit
if len(validAttempts) >= rl.limit {
rl.attempts[ip] = validAttempts
return false
}
// Add new attempt
validAttempts = append(validAttempts, now)
rl.attempts[ip] = validAttempts
return true
}
func (rl *RateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
cutoff := time.Now().Add(-rl.window)
for ip, attempts := range rl.attempts {
var validAttempts []time.Time
for _, attempt := range attempts {
if attempt.After(cutoff) {
validAttempts = append(validAttempts, attempt)
}
}
if len(validAttempts) == 0 {
delete(rl.attempts, ip)
} else {
rl.attempts[ip] = validAttempts
}
}
}
// Middleware for rate limiting
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = forwarded
}
if !rl.Allow(ip) {
http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
return
}
next(w, r)
}
}