fix(login): prevent user enumeration via differential password reset UI#41586
fix(login): prevent user enumeration via differential password reset UI#41586DeepDiver1975 wants to merge 4 commits into
Conversation
|
Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a changelog item based on your changes. |
DeepDiver1975
left a comment
There was a problem hiding this comment.
Code Review — fix(login): prevent user enumeration via differential password reset UI
Overview: Removes the per-user canChangePassword() check that caused the "Reset it?" link to appear only for valid LDAP accounts, leaking user existence to unauthenticated callers. The reset link now shows unconditionally (unless lost_password_link is set to disabled).
Correctness
The fix is correct and complete. The key change is removing the if ($userObj instanceof IUser) { $parameters['canResetPassword'] = $userObj->canChangePassword(); } block, so canResetPassword defaults to true for all users regardless of existence or backend type. This collapses the differential response.
The loginName logic refactor is also correct: setting $parameters['loginName'] = $user unconditionally first and then overriding it only when $u->getUserName() returns a non-empty string is cleaner and logically equivalent to the previous two-step check.
Tests
Two new tests are well-targeted:
testShowLoginFormWithPasswordResetOptionAlwaysTrueWhenNoLostPasswordLink— verifiescanChangePasswordis never called andcanResetPasswordis alwaystruefor a valid usertestShowLoginFormCanResetPasswordUniformForNonExistentUser— explicitly tests the enumeration case (non-existent user must yield samecanResetPassword=trueas a valid user)
Both testShowLoginFormForUserNamedNull updates are correct (same fix applied).
One minor note
The $parameters['user_autofocus'] = false line remains inside the if ($u !== null && ...) branch. This means user_autofocus is only set to false when a valid user with a non-empty username is found; for non-existent users it falls through to the else branch where user_autofocus is not set. This was the pre-existing behaviour and is not changed by this PR — not an issue introduced here.
Summary
| Aspect | Assessment |
|---|---|
| Security fix | ✅ Correct — differential response eliminated |
| Tests | ✅ Both enumeration cases explicitly covered |
| Regression risk | Low — only the reset-link visibility changes |
Verdict: Ready to merge.
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
6a7d15e to
d3b163f
Compare
DeepDiver1975
left a comment
There was a problem hiding this comment.
Code Review — fix(login): prevent user enumeration via differential password reset UI (updated)
Overview: This is a significant rewrite of the PR compared to the previously-reviewed version. It now includes a full diff of LoginController.php with the user-enumeration fix and also cleans up a duplicate loginName assignment block.
User enumeration fix
Before: canResetPassword was set to false when a valid user existed on a backend where canChangePassword() returned false (e.g. LDAP). This made login-form responses distinguishable: LDAP user → no reset link, non-existent user → no reset link but for different reasons. In practice the original code set canResetPassword based on $userObj->canChangePassword(), which leaked the existence of a user on backends that don't support password change.
After: canResetPassword is unconditionally true unless lost_password_link === 'disabled'. The backend capability check is removed entirely. ✅
loginName resolution cleanup
The PR also removes a duplicated loginName/user_autofocus assignment block that appeared after the alt_login section and was clobbering the LDAP username resolution:
// Removed — this block was overwriting $parameters['loginName'] back to
// the raw $user value after the UUID→displayName resolution above.
if ($user !== null && $user !== '') {
$parameters['loginName'] = $user;
...
}The earlier block correctly resolves $u->getUserName() when the user object exists; the later redundant block was resetting it. Removal is correct. ✅
Test changes
testShowLoginFormWithPasswordResetOptionAlwaysTrueWhenNoLostPasswordLink:
- Asserts
canChangePasswordis never called ✅ - Asserts
userManager->get()called exactly once (not twice as before) ✅ - Asserts
canResetPasswordis alwaystrue✅
testShowLoginFormLdapUsernameResolutionNotClobbered:
- New test: passes a UUID internal username, asserts
loginNameresolves togetUserName()('john.doe'), not the raw UUID ✅ - Directly tests the removed duplicate-block regression ✅
testShowLoginFormCanResetPasswordUniformForNonExistentUser:
- New test:
userManager->get()returnsnull(non-existent user), assertscanResetPassword === true✅ - This is the key regression guard for the user enumeration fix ✅
testShowLoginFormForUserNamedNull:
- Updated:
canChangePasswordno longer called,canResetPasswordnowtrue✅
Summary
| Aspect | Assessment |
|---|---|
| User enumeration closed | ✅ Backend capability no longer consulted |
| LDAP name resolution | ✅ Duplicate clobbering block removed |
| Non-existent user indistinguishable | ✅ Both paths → canResetPassword=true |
| Tests | ✅ Three new/updated tests directly assert the fix |
| Changelog | ✅ Present |
Verdict: Ready to merge.
showLoginForm() called userManager->get($user) and checked canChangePassword() to decide whether to show "Wrong password. Reset it?" or just "Wrong password.". LDAP users (canChangePassword=false) produced a different UI than non-existent users (check skipped), giving attackers an oracle to identify users on non-password-change backends. Remove the backend capability check entirely. canResetPassword now stays true unless lost_password_link is explicitly set to "disabled", making the login-failure response identical for all users. Signed-off-by: Thomas Müller <thomas.mueller@owncloud.com> Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…arning Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…bered LDAP resolution A second if/else block after the alt_login assignment was unconditionally overwriting $parameters['loginName'] with the raw $user input, silently discarding the getUserName() resolution done in the first block for LDAP accounts whose internal username is a UUID. The first block already handles all cases correctly; the duplicate was dead code with a functional side-effect. Adds a regression test (testShowLoginFormLdapUsernameResolutionNotClobbered) that passes an internal UUID as $user and asserts loginName is the resolved display name, not the raw UUID. Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
d3b163f to
a350416
Compare
Summary
showLoginForm()showed different UI for LDAP users (no "Reset it?" link) vs non-existent users, creating an unauthenticated oracle to enumerate LDAP userscanChangePassword()backend check;canResetPasswordis alwaystrueunlesslost_password_link=disabledSecurity Impact
Low — user enumeration limited to backends without password-change support (e.g. LDAP); requires login attempts
Note
This PR touches the same files as
security/fix-login-brute-force— merge that one first.Test plan
testShowLoginFormCanResetPasswordUniformForNonExistentUserand updated existing tests assertcanResetPassword=trueregardless of backend type; fail without fixmake test TEST_PHP_SUITE=core/Controller🤖 Generated with Claude Code