Problem/Motivation
Assume I change email provider or leave a job. I or an attacker requests a password reset link.
I now change the email address on my drupal account, which I've been logged into on my home computer
My former employer or someone with access to my old email account can use the pre-existing one-time login link to gain access to my account (or the whole site if I'm an admin).
Proposed resolution
Invalidate one-time login links if the email address is changed. The easiest fix is to include the current email address as part of the data in user_pass_rehash
Remaining tasks
User interface changes
n/a
API changes
possible small change to user password reset API. In Drupal 8 it looks as though it should take a full account object, not the specific values so we can make improvements later without breaking the API.
Looks like no API change needed in 7 since we are already making a database query where we can load the email string.
Data model changes
None
original report was part of the Drupal 8 bug bounty
https://tracker.bugcrowd.com/submissions/4ab3b7847cdbf5b6d2e946529324105...
Beta phase evaluation
Issue category | Bug because functionality is broken as to how it "should be". (from a security perspective) |
---|---|
Issue priority | Major because it is a valuable security hardening. Not critical because the issue also exists in Drupal 7 core and is not considered a critical security issue there. |
Prioritized changes | The main goal of this issue is security |
Disruption | Very minor disruption to any module using the user_pass_rehash() function. In my search of public 8.0.x modules, there are none. The only non-core, 7.x modules I could find that used this function were in the i18n suite so that leads me to believe that this function is rarely used outside of core. |
Comments
Comment #1
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedComment #2
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedComment #3
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedA less disruptive variation on this might be just to update the login time when the password is change?
However, that doesn't handle the situation where an admin changes a user's email for them (e.g. becuase their email was hacked, etc)
Comment #4
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedI think injecting the user's email address into the hash is the best solution here since it handles both the user changing their email address and an admin changing it on their behalf. Working on a patch.
Comment #5
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedI think changing the Drupal 8 method to to take the loaded account object might be the best course - in 6 and 7 we can just load the data perhaps to avoid further API changes.
Comment #6
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedpwolanin and I discussed this in IRC and we both agree that passing the account object to user_pass_rehash is better than just passing in the email address as it provides future possibilities of adding other things to the password reset token. As part of this we also discovered that the phpdoc for user_cancel_url was incorrect as it talked about including certain properties on the account object (we call methods on the object). Patch includes a test to get a password reset token, change the user's email address, and then try and redeem the password reset token (which should properly give an error about being invalid). Let's see what testbot thinks.
Comment #7
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedLooks like a good start. in user_cancel_url() and user_pass_rehash() the 1st param should be type-hinted on the UserInterface
Comment #8
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedYup, good catch.
Comment #9
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commenteduser.module already has at the top:
So you don't need to spell out the full thing, just like:
Comment #10
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedSwitched to just be UserInterface.
Comment #11
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedminor:
Would be better to concatenate an actual string rather than relying on the cast from int.
Comment #12
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedConverted our email change to be a string "1" instead of an int 1.
Comment #13
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedThanks. This sting concatenation also looks a little odd in terms of code style:
I'd either keep each one on a single line, or build $data up using the
.=
operator.https://www.drupal.org/coding-standards#concat
Comment #14
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedOk. I had originally done it that way for 2 reasons -- it kept the lines under 80 characters and it would make any future changes easier to track through git diffs because we wouldn't have a huge line of concatenations to compare. With that in mind, I've adjusted it to use the
.=
format.Comment #15
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedIn don't think we have a real hard requirement to stay under 80 except for comments.
https://www.drupal.org/coding-standards#linelength
I'd probably put at least the $key concat on one line, or even inline it in the hmac call. It seems odd to break up just 2 items.
The code comment inside user_pass_rehash can probably be removed also, I don't think it's adding any useful information.
Comment #16
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedAlso, i think the change to the doxygen is wrong, since the login reset URL doesn't include the email address
Comment #17
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedIn rereading the coding standards, it looks like the goal is to keep things under 80 lines but there are exceptions to every rule and they're more like guidelines in this case. There are certainly plenty of places in Core that exceed the 80 character length. At the same time, I've seen project applications for new modules put back to needs work because an if condition or string concatenation is over 80 characters...
In this case, I can see just putting $key in the hmac call because it is unlikely to change and relatively short. I think putting the $data concatenation on separate lines (as it is in this patch) provides more value than just the 80 character guideline. If we ever change this in the future, it will be very easy to read what got added/removed because you can just look for the
vs
Updated to reflect what the hash contains and not what the URL contains. I also removed the sentence about retrieving values from the database...I'm guessing it was left over from 7.x
Comment #18
Fabianx CreditAttribution: Fabianx as a volunteer commentedRTBC from my side, but we might need a beta evaluation
Comment #19
catchAlso a change record.
Comment #20
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedI took a first stab at creating the change record -- https://www.drupal.org/node/2515902. Feedback welcome as this is my first time writing one and I largely just tried to follow the guide (https://www.drupal.org/contributor-tasks/draft-change-record).
I also started to add a beta evaluation (leaving Needs beta evaluation tag since it isn't complete). @pwolanin -- can you help/add your thoughts to the classification of this as a bug?
Comment #21
catchComment #22
Fabianx CreditAttribution: Fabianx as a volunteer commentedThanks, tweaked it a little: Beta evaluation looks great!
Comment #24
catchCommitted/pushed to 8.0.x, thanks!
Moving to 7.x for backport.
Comment #25
Fabianx CreditAttribution: Fabianx as a volunteer commentedComment #26
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedComment #27
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedFor 7.x, it seems like there are a couple options.
One might be defining a new API function that core calls directly with an account object and leaving behind the old one as a wrapper that loads the email?
Another would be doing roughly the same as was done for the SA where uid was added and add email as a final, optional, argument and look it up if not supplied.
Probably worth getting feedback from David_Rothstein.
Comment #28
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedUnassigning while we wait for feedback from David_Rothstein
Comment #29
Fabianx CreditAttribution: Fabianx as a volunteer commentedComment #30
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commentedNot sure I really have anything to add here.
In retrospect I guess it would have been nice if we had added the new function in that security release - would have been more future-proof since we were asking everyone to change their code at that point anyway :)
Both of the suggestions in #27 seem backwards compatible. I guess adding a new function that takes the whole account object would be more future-proof now also.
Comment #31
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedI think the best option here is to have the function take the whole account object so we can add other pieces of metadata about the user in the future without breaking BC.
Action items as I see it (working on these):
user_pass_rehash()
which takes the whole account object (D8 style).user_pass_rehash()
to load the full user object if the uid is provided or lookup the uid and then load the full user object (to maintain the existing BC). Then call the newuser_pass_rehash()
with the account object.user_pass_rehash()
works as well as the BC layer (both with and without providing a UID).Comment #32
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commentedMaybe it would be slightly better to do it the other way around (have the new function call the old one)? Then we avoid messing with the existing code path as much as possible - if you call the old user_pass_rehash(), a user_load() never needs to get triggered. It would still need to load the mail from the database though.
It probably doesn't matter a whole lot either way.
Comment #33
rvtraveller CreditAttribution: rvtraveller as a volunteer commentedI'm not sure I fully follow this. If we just create a new function to call the old function and the old function looks up the email address, what value does the new function have? At this point in the life cycle of D7 I'm not sure we will ever fully remove the old function.
I'm attaching 3 different patches with 3 different approaches to this.
Of all of these, I like the simplicity of #1 and #2 best. If it were me, I'd probably go with #2 and count on the static caching in user_load() to make the overhead minimal.
Comment #37
izmeez CreditAttribution: izmeez commentedAny thoughts for D7 in terms of the three alternative patches offered in comment #33 ?
Comment #38
Fabianx CreditAttribution: Fabianx as a volunteer commented#32:
The problem is that if we leave user_pass_rehash() unmodified, then existing contrib code would not have the added security.
What about an option 4):
- Add $mail as new (optional) parameter to user_pass_rehash()
- If mail is not set, load the account object via uid and get the mail from there as 2nd BC layer
Probably for a 7.60 release I would even be fine to add $mail as new required parameter and break BC by putting a notice ...
@David: What do you think?
Comment #41
poker10 CreditAttribution: poker10 at ActivIT s.r.o. commentedAccording to the backport policy I have created a D7 follow-up issue: #3306390: [D7] Changing email address should invalidate one-time login links, so we can finish the backport there. There are multiple options to consider and the discussion can continue there.
Therefore we can close this issue as fixed for D8.