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) } }