Code review

This commit is contained in:
binwiederhier
2026-06-16 21:18:58 -04:00
parent d8c87d04e7
commit 99bc803271
4 changed files with 56 additions and 13 deletions
+12 -13
View File
@@ -825,22 +825,21 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
// resolveResetTarget resolves a reset identifier to a single account and its primary email. // resolveResetTarget resolves a reset identifier (username or primary email) to a single account
// The identifier is tried first as a username, then as a primary email address. It returns // and its primary email. It applies the reset policy on top of the lookup: provisioned users are
// ok=false if no account with a primary email matches (reset requires a verified primary email). // excluded, and ok=false is returned unless the account has a verified primary email (reset
// requires one, and that is where the link is sent).
func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) { func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) {
if u, err := s.userManager.User(identifier); err == nil && u != nil && !u.Provisioned { u, err := s.userManager.UserByEmailOrUsername(identifier)
if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" { if err != nil || u == nil || u.Provisioned {
return u.ID, primary, true
}
}
if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil {
if u, uerr := s.userManager.UserByID(uid); uerr == nil && !u.Provisioned {
return uid, identifier, true
}
}
return "", "", false return "", "", false
} }
primary, err := s.userManager.PrimaryEmail(u.ID)
if err != nil || primary == "" {
return "", "", false
}
return u.ID, primary, true
}
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated): // handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
// it validates the token and sets the new password. Existing access tokens stay valid. // it validates the token and sets the new password. Existing access tokens stay valid.
+31
View File
@@ -259,6 +259,37 @@ func TestAccount_PasswordReset_ByEmail(t *testing.T) {
}) })
} }
func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
// Account A (the email owner): user "ben" with verified primary email "phil@example.com"
verifyEmailFor(t, s, mailer, auth, "phil@example.com")
// Account B (the squatter): a different account whose USERNAME looks like A's email, with
// its own, different verified primary email
require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false))
squatter, err := s.userManager.User("phil@example.com")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com"))
// Reset by the ambiguous identifier: the verified email must win over the look-alike username
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil)
require.Equal(t, 200, rr.Code)
require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A)
require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B)
// The token resets account A (ben); the squatter's password is untouched
token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/")
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
require.Equal(t, 200, rr.Code)
require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset
require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected
})
}
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) { func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, _ := newEmailTestServer(t, databaseURL) s, mailer, _ := newEmailTestServer(t, databaseURL)
+13
View File
@@ -515,6 +515,19 @@ func (a *Manager) UserByID(id string) (*User, error) {
return a.readUser(rows) return a.readUser(rows)
} }
// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary
// email address and then as a username. A verified, owned email takes precedence over a
// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns
// ErrUserNotFound if neither matches.
func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil {
if u, err := a.UserByID(userID); err == nil {
return u, nil
}
}
return a.User(identifier)
}
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise // userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
func (a *Manager) userByToken(token string) (*User, error) { func (a *Manager) userByToken(token string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix()) rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())