Problem/Motivation
User password reset URLs can be used to enumerate usernames.
For example [site_url}/user/reset/[user_id]/1/1
For Drupal 7 or Drupal 8, if you're logged in and visit a URL like the above it happens via this message:
Another user (%other_user) is already logged into the site on this computer, but you tried to use a one-time link for user %resetting_user.
For Drupal 8 only, if you're logged out and visit a URL like the above, you get a "Reset password" form with the following content (which includes the username):
This is a one-time login for %user_name and will expire on %expiration_date.
Click on this button to log in to the site and change your password.
When you submit the form it tells you then that the link is invalid. But it never should have shown the form in the first place.
I am reporting this issue because a customer(bank) pointed out it was a leakage of customer information in their scenario. It is currently possible (v7.51 + v8.19) to get the username of user in the system by requesting a password reset for that UID with a random timestamp and hash.
I reported this issue to the security team already and they concluded the following :
I believe this issue can be fixed in public without a security advisory because of our policy on username disclosure: https://www.drupal.org/node/1004778
Steps to reproduce
Proposed resolution
Remaining tasks
Has case 1 been fixed?
User interface changes
API changes
Data model changes
Release notes snippet
| Comment | File | Size | Author |
|---|---|---|---|
| #79 | 2828724-79.patch | 10.4 KB | alexpott |
| #79 | 76-79-interdiff.txt | 698 bytes | alexpott |
| #76 | 2828724-76.patch | 10.39 KB | alexpott |
| #76 | 74-76-interdiff.txt | 3.83 KB | alexpott |
| #74 | 2828724-74.patch | 11.09 KB | alexpott |
Issue fork drupal-2828724
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 2828724-username-enumeration-via
changes, plain diff MR !546
Comments
Comment #2
David_Rothstein commentedNote that for Drupal 8 this happens regardless of whether you are logged in or logged out, whereas for Drupal 7 it only happens if you're logged in:
When you submit the form it tells you then that the link is invalid. But it never should have shown the form in the first place.
The fix in both cases should be to prioritize the "link is invalid" message over anything else, so it always shows when the link is invalid.
Side note: The overall issue is exacerbated a bit by the fact that the raw username is displayed here (not the display name that contrib modules which hide the username would use) but that part is covered by #2801645: Use \Drupal\user\UserInterface::getDisplayName() on user pages instead of the plain User::getUsername() and related issues.
Comment #3
dpiComment #5
malcomio commentedThere is a patch on #2863468: User password reset form exposes usernames even when hash is invalid that addresses the case of anonymous users, but not authenticated users.
Comment #6
malcomio commentedHere is a patch, based on the work by @cburschka and @pwolanin in #2863468: User password reset form exposes usernames even when hash is invalid, that should address both scenarios.
I'm seeing some errors when running tests locally though - will try to sort those out.
Comment #7
malcomio commentedComment #8
malcomio commentedThe patch breaks the scenario where a logged in user clicks a valid one-time login link for another user.
Comment #9
malcomio commentedAnother attempt, which seems to work, as far as I can understand the various scenarios.
However, 4 tests are failing in UserPasswordResetTest, and 2 in testResetImpersonation, but I'm not sure why :(
Comment #13
David_Rothstein commentedComment #14
vijaycs85Fixed all fails and had to make some code changes to match with issue summary.
Comment #15
smazJust testing this, it seems to work in the majority however if you replace the user ID with a non-existing user ID (or anything else) then you get an error.
i.e. changing:
http://local.dev/user/reset/1/1234567890/ABCXYZ....
to
http://local.dev/user/reset/9999999/1234567890/ABCXYZ....
or
http://local.dev/user/reset/abc/1234567890/ABCXYZ....
Causes:
The patch has removed a check for $user not being NULL & the user being active in getResetPassForm().
I've added this back in, and the user gets an Access Denied message now, as per before this patch.
Does this need a test?
Comment #17
piggito commentedThe test was already there but it was modified along with the check removed so I'm adding a patch with the roll back for test.
Comment #19
smazThis is now working fine, thanks!
Comment #20
smazI take that back, sorry!
With this patch, appending /login to any reset URL will log you in automatically for the specified ID, so for example:
Changing
http://local.dev/user/reset/2/1234567890/ABCXYZ
to
http://local.dev/user/reset/1/1234567890/ABCXYZ/login
Will successfully log you in as that user.
I'll try and look into this today.
Comment #23
gg4 commentedComment #25
kleinmp commentedHere's the patch roled against 8.6.x. I replaced all of the drupal_set_messages iwth Drupal::messenger...
Comment #26
sk33lz commentedThe patch in #25 fails against 8.8.x and needs a re-roll.
Comment #27
tatarbjpatch is re-rolled on 8.8.x-dev
Comment #28
tatarbjComment #30
yogeshmpawarComment #31
yogeshmpawarMaybe it will solve the problem of test fails & also added an interdiff for patches.
Comment #34
f.mazeikis commentedOn 8.8.1 #31 was failing to apply for me, rerolled to be compatible with 8.9.x and added some changes to tests.
Comment #35
ravi.shankar commentedComment #36
nicrodgersFails testing of this scenario described in the description:
The message still appears and displays the username, even with patch 34 applied.
Steps to reproduce:
1. Install core and apply patch 34
2. Login as user 1
3. Create a new user account, get their user id, we'll assume it's uid 2 for this next step
4. Go to /user/reset/2/1/1
Actual behaviour:
* Message shows username of user 2, eg: Another user (test2) is already logged into the site on this computer, but you tried to use a one-time link for user admin. Please log out and try using the link again.
* https://www.dropbox.com/s/be4bgjr8urh1pld/Screenshot%202020-04-07%2014.4...
Expected behaviour:
* Message should not reveal the username of the other user.
Comment #37
Lal_I have edited the message but if it requires any other fixes please let me know.
Comment #39
Lal_forgot the test changes...
Comment #40
fabianderijkThanks for the patch. It works for me, I get redirected to the password reset form with the message that the link is expired when I go www.example.com/user/reset/1/1/1. When I use a actual link the user get's logged in.
Comment #42
henry tran commentedI've re rolled to 8.9.1
Comment #44
hash6 commentedComment #45
ridhimaabrol24 commentedFixing test cases.
Comment #46
ridhimaabrol24 commentedHi @hash6. Didnt notice you assigned this to yourself. I worked on it and uploaded the patch. it seems u just assigned the patch few seconds back. Sorry for that.
Comment #47
semiaddict commentedHi,
I'm also trying to deal with this issue, which I consider to be a vulnerability in my setup.
Thank you for the work already done.
I am attaching a new patch that removes some unnecessary checks in
UserController::getResetPassForm. It also moves the timeout check above the more expensivevalidPassResetHashcheck.Comment #48
tanubansal commentedPatch #45 is working fine.
This can be moved to RTBC if no further code changes are there
Comment #49
jelle_sI took a stab at a patch for D7. I moved the checks for validating the hash, timestamp, ... to the top of the function and altered the message when an other user is already logged in like they did in the patch for D8/9.
Comment #51
spokjeReroll of patch #47 against
9.2.xComment #52
spokjeSeems that have inherited some spacing issues, retry
Comment #53
jjmackow commentedI tried applying patch #42 to Drupal 8.9.9 and it failed.
That being said, would it be adequate from a security standpoint to simply add a new TWIG template to the theme and override the form.html.twig file with say, form--user--pass--reset.html.twig?
the 'children' variable within that TWIG returns a string of:
This is a one-time login for jsmith and will expire on January 01, 1970.
I was thinking of getting 'children' rewritten with
This is a one-time login for j***h
and displaying the username string with a mask.
Comment #54
baikho commentedAdded a reroll of #52 with username mask suggestion of #53.
Comment #55
ravi.shankar commentedAdded a patch will fix CS issue of patch #54.
Comment #57
spokjeOnwards and upwards in the MR.
Comment #59
spokjeComment #61
elusivemind commentedRe-Roll of Patch 55 for Drupal 9.2
Comment #63
elusivemind commentedRe-submitting for testing
Comment #64
zatox commentedI don't understand why we are trying to mask the usernames. It's only partialy solving the issue.
If the link is valid then we can show the real username. That's not a problem.
If the link is not valid then we shouldn't show the form at all (that's the problem) but do something else like show a 403 for example or show a page that says "the one time login link you used is invalid".
There is no point in showing the form with a masked username if the link used is not valid.
Am I missing something?
Comment #67
dieterholvoet commentedComment #68
ranjith_kumar_k_u commentedRerolled #63
Comment #70
ravi.shankar commentedFixing failed tests of patch #68.
Comment #72
alexpottI think the changes in #70 are not quite right. The problem is that messages should be the same if you use the form - or use the one time link that bypasses the form and logs you in directly. The changes in #70 make it so that the link that logs you in directly does not provide the same messages as it currently does about expired links and that feels wrong.
Comment #73
alexpottPatched attached moves the redirect on error to it's own function from
\Drupal\user\Controller\UserController::resetPassLogin(). This allows that function and\Drupal\user\Controller\UserController::resetPass()do exactly the same thing regardless if a user goes to /user/reset/1/HASH/TIME or /user/reset/1/HASH/TIME/login.My patch also fixes a bug in \Drupal\Tests\user\Functional\UserLoginHttpTest caused by not refreshing the container. The test on HEAD assumes that after pressing the log in button on the reset pass form you are logged in but actually because of the stale container that don't actually get correct login links. With this change this log in links no redirect and have an error message whereas before you'd have to press the log in button to see the message.
Comment #74
alexpottHere's test evidence that the automatic login route and the go via a form and log button route now work the same.
Comment #75
bbralaI've ran through the code. Also tested manually with an reset link on a deactivated user and a normal user. I get a 403 with an deactivated user. And a redirect regarding an invalid link with another user.
It is great this is refactored to use a method! Way cleaner to have a single method to determine the error.
I would invert this if though and either:
or
This would be more readable mentally in my opinion.
This is the same pattern, i really like to have an easy condition and then return early :)
My first look at this had me questioning this method name. I mean, only one of the 3 outcomes is a redirect. As the docblock mentions, it validates user, hash and timestamp.
Not sure how this would be better though. A thought was, perhaps it should only throw exceptions if something is wrong, but that kinda complicates the implementation as a whole.
Basically, it does what is advertised, it just feels like a weird method with those 3 totally different outcomes.
Tested the tests without this, because i was skeptical. But seems they fail otherwise.
Love the extra coverage for the login links <3
Great progress, thanks @alexpott, this seems like it is quite close.
Comment #76
alexpottRe #75.1 / 2 sure - fixed in the patch attached
Re #75.3 I added more docs. I think the 3 outcomes - exception / redirect / null is okay - and part of making things consistent.
Re #75.4 Yeah the test atm is not currently testing what it thinks it is. If you install a module in a test you need to ensure the container attached to the test object is updated. This is why in Browser tests I think we should not have the container on the test object. This is an old argument.
Re #75.5 that's to ensure we don't lose test coverage as that's what we were testing prior to this change.
Comment #77
bbralaThanks for the changes and the explaination.
I did consider the 403 for a non existant user also. Since that does expose the fact a uid exists, but I concluded that this is fine. Since the most important thing to solve is the exposure of usernames.
This looks great! I'm all for getting this in. I'll probably generate a patch for 9.4.x later also, since I think this is an improtant improvement in privacy that i'd roll out to every site we run.
ANyways, RTBC, thanks!
Comment #78
catchLooks like this should be
@return \Symfony\Component\HttpFoundation\RedirectResponse|NULL?Comment #79
alexpottIndeed... thanks for the review @catch. Patch attached fixes the docs... the return typehint was already correct.
Comment #82
catchHad a last look through and couldn't find anything to complain about, the code being moved is more or less identical it's just changing execution order so the redirect happens in more cases.
Double checked there's no actual string change here and there isn't making this eligible for 10.0 and 9.5 too, so committed/cherry-picked to the respective branches.
Did my best with issue credits but this is a long issue with a lot of people on it, so might not be perfect.
Comment #83
bbralaThat is awesome catch. Thanks! We just got a bit more prinlvacy friendly! :)
Comment #85
poker10 commentedI have created a D7 backport issue. We probably want to backport this security/privacy improvement also there. See: #3326994: [D7] Username enumeration via one time login route
Comment #86
poker10 commentedWhile working on the D7 backport, I have noticed that only one usecase for D9/10 was fixed. See #2 or #36.
1. For Drupal 7 or Drupal 8, if you're logged in and visit a URL like the above it happens via this message:
2. For Drupal 8 only, if you're logged out and visit a URL like the above, you get a "Reset password" form with the following content (which includes the username):
I see that the usecase 2 is fixed now - when logged out, you are correctly redirected. But the username seems to be still revealed if the user is logged in and use that URL with specific pattern (usecase 1) - tested on 10.1.0-dev. Was there a decision to fix only the part when the user is logged out or it was missed?
Thanks!
Comment #87
quietone commentedI am setting this back to NW for the question raised in #86, which is asking if Case 1 has been fixed. I skimmed the comments and didn't see an answer.
Comment #88
alexpott@poker10 nice catch - I think that that means we should add a follow-up issue to address that case as it will require a string change. Once the follow-up exists I think this issue should be marked as fixed.
Comment #89
alexpottI created #3327294: Username enumeration via one time login route when logged in as another user to account for #86.
Comment #90
quietone commented@alexpott, thanks.
Comment #92
quietone commentedI forgot to remove the needs follow up tag.