From f8d90e3d80ec798689be5fdf792e6c1401ad748f Mon Sep 17 00:00:00 2001 From: grm Date: Fri, 18 Sep 2026 13:46:11 +0300 Subject: Security: Throttle login attempts with a per-address, per-account token bucket Nothing stopped a bot from trying passwords against /webadmin as fast as bcrypt would go. A small in-memory limiter (stdlib only, one process) now refuses a login with 429 once an address, or an account, has made ten attempts, and lets one more through every six seconds; keying on both means many addresses guessing one account are throttled too. Refusals are logged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/ratelimit.go | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 internal/web/ratelimit.go (limited to 'internal/web/ratelimit.go') diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go new file mode 100644 index 0000000..b019a4f --- /dev/null +++ b/internal/web/ratelimit.go @@ -0,0 +1,87 @@ +package web + +import ( + "net" + "net/http" + "sync" + "time" +) + +// limiter is a token bucket per key (a client address, a username) for the +// few anonymous endpoints worth abusing: login guesses and the search's +// regex scan. Everything else is left to the reverse proxy's limit_req. It +// lives in memory — one process, no shared state needed — and forgets a key +// once its bucket has been full for a while. +type limiter struct { + mu sync.Mutex + rate float64 // tokens per second + burst float64 + buckets map[string]*bucket + lastSweep time.Time + now func() time.Time // tests replace it +} + +type bucket struct { + tokens float64 + at time.Time +} + +const limiterSweep = 10 * time.Minute + +func newLimiter(perMinute, burst int) *limiter { + return &limiter{rate: float64(perMinute) / 60, burst: float64(burst), buckets: map[string]*bucket{}, now: time.Now} +} + +// allow takes one token for key and says whether there was one. +func (l *limiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + if l.lastSweep.IsZero() { + l.lastSweep = now + } else if now.Sub(l.lastSweep) > limiterSweep { + l.lastSweep = now + for k, b := range l.buckets { + if l.fill(b, now) >= l.burst { + delete(l.buckets, k) + } + } + } + b, ok := l.buckets[key] + if !ok { + b = &bucket{tokens: l.burst, at: now} + l.buckets[key] = b + } + if l.fill(b, now) < 1 { + return false + } + b.tokens-- + return true +} + +// fill credits the time since the last visit and returns the balance. +func (l *limiter) fill(b *bucket, now time.Time) float64 { + b.tokens = min(l.burst, b.tokens+now.Sub(b.at).Seconds()*l.rate) + b.at = now + return b.tokens +} + +// clientIP is the address requests are throttled by: the peer's. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// throttle answers 429 and logs when key has no tokens left; false means the +// request was refused. +func (s *Server) throttle(w http.ResponseWriter, r *http.Request, l *limiter, key string) bool { + if l.allow(key) { + return true + } + logf("throttled %s %s from %s", r.Method, r.URL.Path, clientIP(r)) + s.fail(w, r, http.StatusTooManyRequests, s.tr(r, "Too many requests. Try again in a minute.")) + return false +} -- cgit v1.2.3