aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/hardening_login_limiter_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/admin/hardening_login_limiter_test.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/admin/hardening_login_limiter_test.go')
-rw-r--r--internal/admin/hardening_login_limiter_test.go164
1 files changed, 164 insertions, 0 deletions
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)
+ }
+}