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/web_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) (limited to 'internal/web/web_test.go') 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) + } +} -- cgit v1.2.3