diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go new file mode 100644 index 0000000..d4f1af5 --- /dev/null +++ b/internal/web/ratelimit.go @@ -0,0 +1,96 @@ +package web + +import ( + "net" + "net/http" + "sync" + "time" +) + +// rateLimiter is a per-key token-bucket limiter, safe for concurrent use. It +// throttles abusive bursts (e.g. password guessing) using only in-process +// state. Each key (a client IP) gets a bucket that starts full and refills at a +// steady rate; a request costs one token. +type rateLimiter struct { + mu sync.Mutex + buckets map[string]*tokenBucket + ratePerSec float64 // tokens replenished per second + burst float64 // bucket capacity (max immediate requests) + now func() time.Time + lastSweep time.Time +} + +type tokenBucket struct { + tokens float64 + last time.Time +} + +// newRateLimiter builds a limiter allowing bursts up to burst requests, then +// one further request every refill interval. +func newRateLimiter(burst float64, refill time.Duration) *rateLimiter { + return &rateLimiter{ + buckets: map[string]*tokenBucket{}, + ratePerSec: 1 / refill.Seconds(), + burst: burst, + now: time.Now, + } +} + +// allow reports whether a request for key may proceed, consuming a token. +func (l *rateLimiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := l.now() + l.sweep(now) + + b, ok := l.buckets[key] + if !ok { + // First request from this key: start full, spend one token. + l.buckets[key] = &tokenBucket{tokens: l.burst - 1, last: now} + return true + } + b.tokens = min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.ratePerSec) + b.last = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// sweep drops fully-recovered buckets so memory stays bounded by the number of +// recently-active keys. A bucket back at capacity is indistinguishable from a +// fresh one, so removing it is safe. Runs at most once per minute. +func (l *rateLimiter) sweep(now time.Time) { + if now.Sub(l.lastSweep) < time.Minute { + return + } + l.lastSweep = now + for key, b := range l.buckets { + if b.tokens+now.Sub(b.last).Seconds()*l.ratePerSec >= l.burst { + delete(l.buckets, key) + } + } +} + +// middleware rejects requests from a client that has exceeded its rate, keyed by +// client IP. +func (l *rateLimiter) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !l.allow(clientIP(r)) { + http.Error(w, "Too many attempts. Please wait a moment and try again.", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +// clientIP returns the request's client IP without the port. The RealIP +// middleware has already resolved X-Forwarded-For / X-Real-IP into RemoteAddr. +func clientIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go new file mode 100644 index 0000000..a26f2ef --- /dev/null +++ b/internal/web/ratelimit_test.go @@ -0,0 +1,53 @@ +package web + +import ( + "testing" + "time" +) + +func TestRateLimiterBurstThenThrottle(t *testing.T) { + now := time.Unix(0, 0) + l := newRateLimiter(3, time.Second) + l.now = func() time.Time { return now } + + // Burst of 3 is allowed from a cold start. + for i := 0; i < 3; i++ { + if !l.allow("1.2.3.4") { + t.Fatalf("request %d should be allowed within burst", i) + } + } + // 4th in the same instant is denied. + if l.allow("1.2.3.4") { + t.Fatal("4th request should be throttled") + } + // A different key has its own bucket. + if !l.allow("5.6.7.8") { + t.Fatal("distinct key should not be throttled") + } + // After the refill interval, one more token is available. + now = now.Add(time.Second) + if !l.allow("1.2.3.4") { + t.Fatal("request should be allowed after refill") + } + if l.allow("1.2.3.4") { + t.Fatal("only one token should have refilled") + } +} + +func TestRateLimiterSweepReclaims(t *testing.T) { + now := time.Unix(0, 0) + l := newRateLimiter(2, time.Second) + l.now = func() time.Time { return now } + + l.allow("1.2.3.4") + if len(l.buckets) != 1 { + t.Fatalf("expected 1 bucket, got %d", len(l.buckets)) + } + // Advance well past full recovery + the sweep interval; the next call sweeps + // the now-recovered bucket before creating a fresh one. + now = now.Add(2 * time.Minute) + l.allow("9.9.9.9") + if _, ok := l.buckets["1.2.3.4"]; ok { + t.Fatal("recovered bucket should have been swept") + } +} diff --git a/internal/web/web.go b/internal/web/web.go index ab50d23..587712e 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -119,8 +120,12 @@ func (s *Server) routes() { // Public auth pages + JSON ceremony endpoints. r.Get("/login", s.pageLogin) r.Get("/signup", s.pageSignup) - r.Post("/login/password", s.auth.LoginPassword) - r.Post("/signup/password", s.auth.SignupPassword) + // Throttle credential submission per client IP to blunt password guessing + // and signup spam. Allows a burst (e.g. fat-fingered retries), then ~1 try + // every 6s; bcrypt's cost is the second line of defense. + authLimit := newRateLimiter(10, 6*time.Second) + r.With(authLimit.middleware).Post("/login/password", s.auth.LoginPassword) + r.With(authLimit.middleware).Post("/signup/password", s.auth.SignupPassword) r.Post("/api/register/begin", s.auth.RegisterBegin) r.Post("/api/register/finish", s.auth.RegisterFinish) r.Post("/api/login/begin", s.auth.LoginBegin)