package webui import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // TestWebmailLoginAccountLockout confirms repeated wrong-password attempts against // one account eventually get refused with a generic lockout message rather than // checking the password at all, and that the lockout doesn't touch a different // account from the same IP (the per-IP throttle, not this per-account layer, would // apply there). func TestWebmailLoginAccountLockout(t *testing.T) { app := newTestApp(t) app.Cfg.Section("Auth").Key("login_attempt_limit").SetValue("3") mux := app.Mux() domains, _ := app.DB.ListDomains() victimID := createTestMailboxWithPassword(t, app, "lockout-victim@example.com", domains[0].ID, "the-real-password-1!") otherID := createTestMailboxWithPassword(t, app, "lockout-other@example.com", domains[0].ID, "another-password-1!") attempt := func(email, password string) *httptest.ResponseRecorder { form := url.Values{"email": {email}, "password": {password}} req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "203.0.113.9:12345" rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec } for i := 0; i < 3; i++ { rec := attempt("lockout-victim@example.com", "wrong password") if !strings.Contains(rec.Body.String(), "Incorrect email or password") { t.Fatalf("attempt %d: expected a normal wrong-password error, got: %s", i, rec.Body.String()) } } locked := attempt("lockout-victim@example.com", "the-real-password-1!") // even the CORRECT password now if !strings.Contains(locked.Body.String(), "Too many failed attempts") { t.Fatalf("expected the account locked out after repeated failures, got: %s", locked.Body.String()) } if locked.Result().Cookies() != nil { for _, c := range locked.Result().Cookies() { if c.Name == mailboxSessionCookieName && c.Value != "" { t.Fatal("expected no session granted while locked out, even with the correct password") } } } // A different account from the same IP is unaffected by the per-account lockout. rec := attempt("lockout-other@example.com", "another-password-1!") found := false for _, c := range rec.Result().Cookies() { if c.Name == mailboxSessionCookieName { found = true } } if !found { t.Fatal("expected a different account from the same IP to log in normally") } _ = victimID _ = otherID } // TestLoginRateLimitPerIP confirms the per-IP throttle kicks in independent of which // account is being tried, once enough requests arrive from one source IP. func TestLoginRateLimitPerIP(t *testing.T) { app := newTestApp(t) app.loginLimiter = newIPRateLimiter(3, 1<<62) // tiny limit, effectively-infinite window for a deterministic test mux := app.Mux() domains, _ := app.DB.ListDomains() createTestMailboxWithPassword(t, app, "ratelimit@example.com", domains[0].ID, "correct-password-1!") attempt := func() *httptest.ResponseRecorder { form := url.Values{"email": {"ratelimit@example.com"}, "password": {"correct-password-1!"}} req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/login", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.RemoteAddr = "203.0.113.10:12345" rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec } for i := 0; i < 3; i++ { if rec := attempt(); rec.Code == http.StatusTooManyRequests { t.Fatalf("attempt %d: unexpectedly rate-limited early", i) } } if rec := attempt(); rec.Code != http.StatusTooManyRequests { t.Fatalf("expected 429 past the per-IP limit, got %d", rec.Code) } }