feat: add per-IP rate limiting to login endpoint (#78)
All checks were successful
check / check (push) Successful in 6s
All checks were successful
check / check (push) Successful in 6s
## Summary Adds per-IP rate limiting to `POST /api/v1/login` to prevent brute-force password attacks. closes #35 ## What Changed ### New package: `internal/ratelimit/` A generic per-key token-bucket rate limiter built on `golang.org/x/time/rate`: - `New(ratePerSec, burst)` creates a limiter with automatic background cleanup of stale entries - `Allow(key)` checks if a request from the given key should be permitted - `Stop()` terminates the background sweep goroutine - Stale entries (unused for 15 minutes) are pruned every 10 minutes ### Login handler integration The login handler (`internal/handlers/auth.go`) now: 1. Extracts the client IP from `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` 2. Checks the per-IP rate limiter before processing the login 3. Returns **429 Too Many Requests** with a `Retry-After: 1` header when the limit is exceeded ### Configuration Two new environment variables (via Viper): | Variable | Default | Description | |---|---|---| | `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | | `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | ### Scope Per [sneak's instruction](#35), only the login endpoint is rate-limited. Session creation and registration use hashcash proof-of-work instead. ## Tests - 6 unit tests for the `ratelimit` package (constructor, burst, burst exceeded, key isolation, key tracking, stop) - 2 integration tests in `api_test.go`: - `TestLoginRateLimitExceeded`: exhausts burst with rapid requests, verifies 429 response and `Retry-After` header - `TestLoginRateLimitAllowsNormalUse`: verifies normal login still works ## README - Added "Login Rate Limiting" subsection under "Rate Limiting & Abuse Prevention" - Added `LOGIN_RATE_LIMIT` and `LOGIN_RATE_BURST` to the Configuration table Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #78 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #78.
This commit is contained in:
122
internal/ratelimit/ratelimit.go
Normal file
122
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRate is the default number of allowed requests per second.
|
||||
DefaultRate = 1.0
|
||||
|
||||
// DefaultBurst is the default maximum burst size.
|
||||
DefaultBurst = 5
|
||||
|
||||
// DefaultSweepInterval controls how often stale entries are pruned.
|
||||
DefaultSweepInterval = 10 * time.Minute
|
||||
|
||||
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
||||
DefaultEntryTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
// entry tracks a per-IP rate limiter and when it was last used.
|
||||
type entry struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// Limiter manages per-key rate limiters with automatic cleanup
|
||||
// of stale entries.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*entry
|
||||
rate rate.Limit
|
||||
burst int
|
||||
entryTTL time.Duration
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// New creates a new per-key rate Limiter.
|
||||
// The ratePerSec parameter sets how many requests per second are
|
||||
// allowed per key. The burst parameter sets the maximum number of
|
||||
// requests that can be made in a single burst.
|
||||
func New(ratePerSec float64, burst int) *Limiter {
|
||||
limiter := &Limiter{
|
||||
mu: sync.Mutex{},
|
||||
entries: make(map[string]*entry),
|
||||
rate: rate.Limit(ratePerSec),
|
||||
burst: burst,
|
||||
entryTTL: DefaultEntryTTL,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
go limiter.sweepLoop()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Allow reports whether a request from the given key should be
|
||||
// allowed. It consumes one token from the key's rate limiter.
|
||||
func (l *Limiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
ent, exists := l.entries[key]
|
||||
|
||||
if !exists {
|
||||
ent = &entry{
|
||||
limiter: rate.NewLimiter(l.rate, l.burst),
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
l.entries[key] = ent
|
||||
} else {
|
||||
ent.lastSeen = time.Now()
|
||||
}
|
||||
l.mu.Unlock()
|
||||
|
||||
return ent.limiter.Allow()
|
||||
}
|
||||
|
||||
// Stop terminates the background sweep goroutine.
|
||||
func (l *Limiter) Stop() {
|
||||
close(l.stopCh)
|
||||
}
|
||||
|
||||
// Len returns the number of tracked keys (for testing).
|
||||
func (l *Limiter) Len() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return len(l.entries)
|
||||
}
|
||||
|
||||
// sweepLoop periodically removes entries that haven't been seen
|
||||
// within the TTL.
|
||||
func (l *Limiter) sweepLoop() {
|
||||
ticker := time.NewTicker(DefaultSweepInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
l.sweep()
|
||||
case <-l.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweep removes stale entries.
|
||||
func (l *Limiter) sweep() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-l.entryTTL)
|
||||
|
||||
for key, ent := range l.entries {
|
||||
if ent.lastSeen.Before(cutoff) {
|
||||
delete(l.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user