diff options
| author | grm <grm@eyesin.space> | 2026-09-18 13:46:11 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-18 13:46:11 +0300 |
| commit | f8d90e3d80ec798689be5fdf792e6c1401ad748f (patch) | |
| tree | 80e37791696168eb09ccccdec37bfc0f75e4f52d /internal | |
| parent | 90578f02d851ab4e28a066404fbcf4be6a0ed9a7 (diff) | |
| download | blogspace-f8d90e3d80ec798689be5fdf792e6c1401ad748f.tar.gz blogspace-f8d90e3d80ec798689be5fdf792e6c1401ad748f.tar.bz2 blogspace-f8d90e3d80ec798689be5fdf792e6c1401ad748f.zip | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/i18n/el.go | 1 | ||||
| -rw-r--r-- | internal/web/handlers_auth.go | 5 | ||||
| -rw-r--r-- | internal/web/ratelimit.go | 87 | ||||
| -rw-r--r-- | internal/web/server.go | 7 | ||||
| -rw-r--r-- | internal/web/web_test.go | 42 |
5 files changed, 141 insertions, 1 deletions
diff --git a/internal/i18n/el.go b/internal/i18n/el.go index 6a97c83..e2523cb 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -269,6 +269,7 @@ var el = map[string]string{ "New passwords do not match.": "Οι νέοι κωδικοί δεν ταιριάζουν.", "Password changed.": "Ο κωδικός άλλαξε.", "Wrong username or password.": "Λάθος όνομα χρήστη ή κωδικός.", + "Too many requests. Try again in a minute.": "Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά σε ένα λεπτό.", // ---- layout ---- "Header": "Κεφαλίδα", diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 1321b2d..1545a4e 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -52,6 +52,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html", map[string]any{"error": s.tr(r, "Wrong username or password."), "username": username, "next": next}) } + // Both buckets must have a token: one address guessing many accounts and + // many addresses guessing one account are throttled alike. + if !s.throttle(w, r, s.loginLimit, "ip:"+clientIP(r)) || !s.throttle(w, r, s.loginLimit, "user:"+strings.ToLower(username)) { + return + } u, err := s.st.UserByUsername(r.Context(), username) if err != nil { if !errors.Is(err, store.ErrNotFound) { 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 +} diff --git a/internal/web/server.go b/internal/web/server.go index f005b76..12440de 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -25,10 +25,15 @@ type Server struct { tpl *templates root http.Handler blog http.Handler + // Anonymous endpoints worth abusing get a token bucket each (ratelimit.go). + loginLimit *limiter // per client address and per username: 10 guesses, then 10 a minute } +// logf is log.Printf, a variable so tests can silence it. +var logf = log.Printf + func NewServer(cfg *config.Config, st *store.Store) *Server { - s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev)} + s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev), loginLimit: newLimiter(10, 10)} s.root = s.rootRoutes() s.blog = s.subdomainRoutes() return s diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 1d4db4c..6324443 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -920,3 +920,45 @@ func TestSingleRangeOnly(t *testing.T) { } } } + +func TestLimiter(t *testing.T) { + now := time.Unix(0, 0) + l := newLimiter(60, 3) // one a second, three at once + l.now = func() time.Time { return now } + for i := 0; i < 3; i++ { + if !l.allow("a") { + t.Fatalf("burst request %d refused", i) + } + } + if l.allow("a") { + t.Error("fourth request allowed") + } + if !l.allow("b") { + t.Error("another key shares the bucket") + } + now = now.Add(time.Second) + if !l.allow("a") || l.allow("a") { + t.Error("refill is not one token per second") + } + now = now.Add(time.Hour) // idle buckets are forgotten at the sweep + l.allow("c") + if _, ok := l.buckets["a"]; ok { + t.Error("full bucket kept after the sweep") + } +} + +// A guessed password is refused with 429 once the address or the account has used its burst. +func TestLoginThrottled(t *testing.T) { + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil) + for i := 0; i < 10; i++ { + s.loginLimit.allow("user:admin") + } + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/webadmin", strings.NewReader("username=Admin&password=x")) + req.Host = "example.com" + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Errorf("got %d, want 429", rec.Code) + } +} |
