Merge SEC-02: normalize lockout response
# Conflicts: # internal/grpcserver/grpcserver_test.go # internal/server/server_test.go
This commit is contained in:
@@ -80,7 +80,9 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if locked {
|
||||
_, _ = auth.VerifyPassword("dummy", u.dummyHash())
|
||||
u.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_locked"}`)
|
||||
u.render(w, "login", LoginData{Error: "account temporarily locked, please try again later"})
|
||||
// Security: return the same "invalid credentials" as wrong-password
|
||||
// to prevent user-enumeration via lockout differentiation (SEC-02).
|
||||
u.render(w, "login", LoginData{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user