Merge SEC-02: normalize lockout response

# Conflicts:
#	internal/grpcserver/grpcserver_test.go
#	internal/server/server_test.go
This commit is contained in:
2026-03-13 01:05:56 -07:00
6 changed files with 228 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
@@ -556,3 +557,77 @@ func TestAccountDetailShowsPGCredsSection(t *testing.T) {
t.Error("human account detail page must not include pgcreds-section")
}
}
// TestLoginLockedAccountShowsInvalidCredentials verifies that a locked-out
// account gets the same "invalid credentials" error as a wrong-password
// attempt in the UI login form, preventing user-enumeration via lockout
// differentiation (SEC-02).
func TestLoginLockedAccountShowsInvalidCredentials(t *testing.T) {
u := newTestUIServer(t)
// Create an account with a known password.
hash, err := auth.HashPassword("testpass123", auth.ArgonParams{Time: 3, Memory: 65536, Threads: 4})
if err != nil {
t.Fatalf("hash password: %v", err)
}
acct, err := u.db.CreateAccount("lockuiuser", model.AccountTypeHuman, hash)
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
// Lower the lockout threshold so we don't need 10 failures.
origThreshold := db.LockoutThreshold
db.LockoutThreshold = 3
t.Cleanup(func() { db.LockoutThreshold = origThreshold })
for range db.LockoutThreshold {
if err := u.db.RecordLoginFailure(acct.ID); err != nil {
t.Fatalf("RecordLoginFailure: %v", err)
}
}
locked, err := u.db.IsLockedOut(acct.ID)
if err != nil {
t.Fatalf("IsLockedOut: %v", err)
}
if !locked {
t.Fatal("expected account to be locked out after threshold failures")
}
mux := http.NewServeMux()
u.Register(mux)
// POST login for the locked account.
form := url.Values{}
form.Set("username", "lockuiuser")
form.Set("password", "testpass123")
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
lockedRR := httptest.NewRecorder()
mux.ServeHTTP(lockedRR, req)
// POST login with wrong password for comparison.
form2 := url.Values{}
form2.Set("username", "lockuiuser")
form2.Set("password", "wrongpassword")
req2 := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form2.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
wrongRR := httptest.NewRecorder()
mux.ServeHTTP(wrongRR, req2)
lockedBody := lockedRR.Body.String()
wrongBody := wrongRR.Body.String()
// Neither response should mention "locked" or "try again".
if strings.Contains(lockedBody, "locked") || strings.Contains(lockedBody, "try again") {
t.Error("locked account response leaks lockout state")
}
// Both must contain "invalid credentials".
if !strings.Contains(lockedBody, "invalid credentials") {
t.Error("locked account response does not contain 'invalid credentials'")
}
if !strings.Contains(wrongBody, "invalid credentials") {
t.Error("wrong password response does not contain 'invalid credentials'")
}
}