Skip to content

fix(auth): failed-login bookkeeping does an unauthenticated whole-row read-modify-write #229

Description

@cristim

Summary

recordFailedLogin writes back the entire user row loaded at the start of login, and UpdateUser sets all 18 columns with no version or conditional predicate. Anyone who knows an email address can trigger it. Interleaved with a legitimate write, the stale snapshot reverts a just-changed password hash, MFA state, group membership or deactivation. The same race loses lockout increments, so the 5-attempt lockout is soft under parallel guessing.

Location

internal/auth/service_user.go:767 at 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd

Failure scenario

recordFailedLogin writes the entire user row it loaded at the start of Login (UpdateUser sets all 18 columns, store_postgres.go:193-240). Anyone who knows the email can trigger it at will. Interleave it with a legitimate write: the victim changes password (ChangePassword writes the new hash), an in-flight failed login for the same email then writes its stale snapshot with the old hash, old MFA state or old group_ids, silently reverting the change; the same race lets an admin's deactivation or group removal be undone by the stale write. It also loses lockout increments (two concurrent failures both write n+1), so the 5-attempt lockout is soft under parallel guessing.

Evidence

func (s *Service) recordFailedLogin(ctx context.Context, user *User) {
    user.FailedLoginAttempts++
    now := time.Now()
    user.UpdatedAt = now
    ...
    if err := s.store.UpdateUser(ctx, user); err != nil {

Suggested fix

Give the store a targeted atomic statement for this path (UPDATE users SET failed_login_attempts = failed_login_attempts + 1, locked_until = CASE ... WHERE id = $1) and use it from recordFailedLogin and completeSuccessfulLogin instead of the full-row write.


Found by the 2026-09-02 codebase audit, finding A03-010, reported by one reviewer and independently confirmed by a second. Full report: docs/audits/codebase-audit-2026-09-02.md.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions