Files
selfpost/internal/web/auth/ratelimit.go
T
mixandCursor 81190a7aad
test / test (push) Has been cancelled
phase1-3: apply review fixes and docs alignment
Implement phase 1 and 2 review actions (DMARC UI CSS fixes, bounded DMARC ingest stdin,
rate-limit eviction hardening, settings/user handler cleanup, shared atomic/supervisor helpers)
and phase 3 documentation/changelog updates including roadmap and accepted-risk notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:06:33 +03:00

109 lines
2.1 KiB
Go

package auth
import (
"sync"
"time"
)
const defaultMaxBuckets = 4096
// rateLimiter is a simple fixed-window per-key counter used to throttle the
// setup and login routes (security.md). Keys are client IPs.
type rateLimiter struct {
max int
window time.Duration
maxBuckets int
mu sync.Mutex
buckets map[string]*rlBucket
}
type rlBucket struct {
count int
windowEnds time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{
max: max,
window: window,
maxBuckets: defaultMaxBuckets,
buckets: make(map[string]*rlBucket),
}
}
func (r *rateLimiter) startSweeper() {
go func() {
ticker := time.NewTicker(r.window)
defer ticker.Stop()
for range ticker.C {
r.mu.Lock()
r.sweep(time.Now())
r.mu.Unlock()
}
}()
}
func (r *rateLimiter) Allow(key string) bool {
now := time.Now()
r.mu.Lock()
defer r.mu.Unlock()
b := r.buckets[key]
if b == nil || now.After(b.windowEnds) {
r.makeRoom(now)
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
r.sweep(now)
return true
}
if b.count >= r.max {
return false
}
b.count++
return true
}
func (r *rateLimiter) makeRoom(now time.Time) {
if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets {
return
}
r.sweep(now)
for len(r.buckets) >= r.maxBuckets {
if !r.evictNearest() {
break
}
}
}
// evictNearest removes the bucket whose window ends soonest, skipping buckets
// that are currently blocked (count >= max) so an attacker cannot flush active
// rate-limit state by filling the map with new keys.
func (r *rateLimiter) evictNearest() bool {
var bestKey string
var bestEnds time.Time
first := true
for k, b := range r.buckets {
if b.count >= r.max {
continue
}
if first || b.windowEnds.Before(bestEnds) {
bestKey = k
bestEnds = b.windowEnds
first = false
}
}
if bestKey == "" {
return false
}
delete(r.buckets, bestKey)
return true
}
func (r *rateLimiter) sweep(now time.Time) {
for k, b := range r.buckets {
if now.After(b.windowEnds) {
delete(r.buckets, k)
}
}
}