From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/admin/hardening_login_limiter_test.go | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/admin/hardening_login_limiter_test.go (limited to 'internal/admin/hardening_login_limiter_test.go') diff --git a/internal/admin/hardening_login_limiter_test.go b/internal/admin/hardening_login_limiter_test.go new file mode 100644 index 0000000..1ee08ed --- /dev/null +++ b/internal/admin/hardening_login_limiter_test.go @@ -0,0 +1,164 @@ +package admin + +// Regression coverage for the admin HTTP /login failed-login limiter: after a +// handful of failures an IP is refused for a cooldown (even with the right +// password), another client is unaffected, a success clears the state, and the +// tracked-address map stays bounded. Telnet login throttling is out of scope +// (net.Conn/Session expose no client identity), so only the HTTP route is +// exercised here. + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// loginStatus posts the login form through the real HTTP route and returns the +// raw status: the client must not follow the success redirect, because both a +// rendered error page and a successful login otherwise report 200. +func loginStatus(t *testing.T, srv *httptest.Server, account, password string) int { + t.Helper() + client := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + resp, err := client.PostForm(srv.URL+"/login", url.Values{"account": {account}, "password": {password}}) + if err != nil { + t.Fatalf("login request: %v", err) + } + defer resp.Body.Close() + return resp.StatusCode +} + +// loginStatusFromIP drives the same handler with an explicit client address, so +// a test can act as a second IP without opening a second listener. +func loginStatusFromIP(t *testing.T, s *AdminServer, remoteAddr, account, password string) int { + t.Helper() + body := url.Values{"account": {account}, "password": {password}}.Encode() + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = remoteAddr + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + return rec.Code +} + +func TestLoginLimiterBlocksFailedLoginsPerIP(t *testing.T) { + s := newSecurityTestServer(t) + lim := s.limitLogins() + base := time.Now() + var clockOffset atomic.Int64 + lim.now = func() time.Time { return base.Add(time.Duration(clockOffset.Load())) } + + srv := httptest.NewServer(s) + defer srv.Close() + + fail := func() int { return loginStatus(t, srv, "alice", "wrong") } + succeed := func() int { return loginStatus(t, srv, "alice", "pw") } + + // Below the threshold a failure still renders the login page with the + // invalid-credentials message: unchanged response content. + for i := 0; i < adminLoginMaxFailures-1; i++ { + if got := fail(); got != http.StatusOK { + t.Fatalf("attempt %d status = %d, want 200", i+1, got) + } + } + + // A successful login clears the failure count, so the next batch starts + // over instead of tripping the threshold partway through. + if got := succeed(); got != http.StatusFound { + t.Fatalf("valid login status = %d, want 302", got) + } + for i := 0; i < adminLoginMaxFailures; i++ { + if got := fail(); got != http.StatusOK { + t.Fatalf("post-reset attempt %d status = %d, want 200 (failure count was not reset)", i+1, got) + } + } + + // Threshold reached: this IP is refused, even with the right password. + if got := fail(); got != http.StatusTooManyRequests { + t.Fatalf("status after %d failures = %d, want 429", adminLoginMaxFailures, got) + } + if got := succeed(); got != http.StatusTooManyRequests { + t.Fatalf("valid credentials while blocked = %d, want 429", got) + } + + // Another client is not punished for this one. + if got := loginStatusFromIP(t, s, "203.0.113.7:5555", "alice", "pw"); got != http.StatusFound { + t.Fatalf("valid login from another IP = %d, want 302", got) + } + + // Once the cooldown lapses the address is served again. + clockOffset.Store(int64(adminLoginCooldown + time.Minute)) + if got := succeed(); got != http.StatusFound { + t.Fatalf("valid login after the cooldown = %d, want 302", got) + } +} + +func TestLoginLimiterCooldownAndReset(t *testing.T) { + base := time.Now() + now := base + l := newLoginLimiter() + l.now = func() time.Time { return now } + + for i := 0; i < adminLoginMaxFailures; i++ { + l.fail("10.0.0.1") + } + if l.allow("10.0.0.1") { + t.Fatal("address allowed immediately after reaching the failure threshold") + } + if !l.allow("10.0.0.2") { + t.Fatal("an unrelated address was blocked") + } + + // A blocked address cannot extend its own block, and the cooldown lapses. + l.fail("10.0.0.1") + now = base.Add(adminLoginCooldown - time.Second) + if l.allow("10.0.0.1") { + t.Fatal("address allowed before the cooldown lapsed") + } + now = base.Add(adminLoginCooldown) + if !l.allow("10.0.0.1") { + t.Fatal("address still blocked after the cooldown lapsed") + } + + // A success clears both the count and any remaining cooldown. + for i := 0; i < adminLoginMaxFailures-1; i++ { + l.fail("10.0.0.3") + } + l.succeed("10.0.0.3") + for i := 0; i < adminLoginMaxFailures-1; i++ { + l.fail("10.0.0.3") + } + if !l.allow("10.0.0.3") { + t.Fatal("a successful login did not reset the failure count") + } +} + +func TestLoginLimiterBoundsTrackedAddresses(t *testing.T) { + base := time.Now() + l := newLoginLimiter() + l.now = func() time.Time { return base } + + for i := 0; i < adminLoginMaxTracked*3; i++ { + l.fail(fmt.Sprintf("198.51.100.%d", i)) + } + if got := len(l.entries); got > adminLoginMaxTracked { + t.Fatalf("limiter tracked %d addresses, want at most %d", got, adminLoginMaxTracked) + } +} + +// TestLoginLimiterKeysOnClientIPNotPort pins the key: two ports of one host +// share a bucket, which is what r.RemoteAddr keying is meant to achieve. +func TestLoginLimiterKeysOnClientIPNotPort(t *testing.T) { + if got := clientIP("127.0.0.1:54321"); got != "127.0.0.1" { + t.Fatalf("clientIP = %q, want 127.0.0.1", got) + } + if got := clientIP("no-port"); got != "no-port" { + t.Fatalf("clientIP without a port = %q, want the value verbatim", got) + } +} -- cgit v1.2.3