-
Notifications
You must be signed in to change notification settings - Fork 5.5k
Fix email enumeration vulnerabilities in password reset and registration flows #5790
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Fix email enumeration vulnerabilities in password reset and registration flows #5790
Conversation
Previously, the password reset functionality exposed whether an email existed in the system by returning different responses for existing vs non-existing accounts. This is a security vulnerability that allows attackers to enumerate valid user emails. This commit makes the following changes: - Always return a success response with a vague message for password reset requests - Remove dependency on paranoid mode for this security feature - Update locale messages to use consistent, non-revealing language - Update tests to reflect the new secure-by-default behavior The new behavior always shows: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." This is a breaking change but improves security by default. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
|
How do you fix the same problem at registration? I usually recommend rate limiting for these, so even if your response indicates the existence of a user, it is difficult to enumerate a large list. |
|
That is a good question. I would likely need a different solution, which I have not considered yet. Rate limiting does the trick to stop or make enumeration untenible. It still exposes data unnecessarily, which gets flagged on every pentest, which then has to be dealt with either through explanation or fixing the issue on a per install basis. |
|
Those pentesters should focus on bigger issues :) I would put such thing maybe as informal on a report and if they don't mention the sign up flow having the same problem, I am not sure what are they doing. |
|
Yes, they should. I don't disagree, but it has been on multiple reports for multiple companies that I've been involved with over the years. This PR came out of using a new Saas build on Rails using Devise, so I decided to stop wishing someone open a PR and just did it. I'm happy to work on the sign-up flow as well. |
|
Also, I could put this behind the |
I am not against this change, I just wanted to point out that this doesn't solve the actual problem, just partially mitigates it. |
When paranoid mode is enabled, registration attempts with existing emails now: - Show the same success message as new registrations - Send an "already registered" email to the existing user - Redirect to sign-in page instead of showing validation errors This prevents attackers from discovering which emails have accounts by attempting to register with them. Changes: - Add paranoid mode check to RegistrationsController#create - Create new mailer method and template for "already registered" emails - Update tests to verify the new secure behavior - Fix test expectations for default non-enumerable password reset 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
Generate a dummy token when user not found to make timing analysis harder. This ensures similar computational work is performed whether the user exists or not, making it more difficult to determine email existence through response time analysis. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
|
I have an idea to resolve this during sign up, but I think that would be too big of a change in devise. |
|
I like that flow. Could it be behind the paranoid flag? I did something like that on my commits, but changed it in the paranoid flag. |
| if recoverable.persisted? | ||
| recoverable.send_reset_password_instructions | ||
| else | ||
| # Perform similar work to mitigate timing attacks |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i like the idea and i was about to do the same. But to properly simulate, we need to also simulate sending an email
I had the same idea as @gregmolnar in his comment about the sign up strategy. Basically here is the flow:
- I take whatever
params[:email]the user sends. - then I don't even check for its existence, I just do a
MaybeSendResetPasswordInstructions.perform_later(params[:email]) - And I redirect the user with a basic notice message "if your email exists in our database you ll get an email"
then obviously my job delegates to devise's Recoveral send_reset_password_instructions API.
The issue here is that the email sending is done in a synchronous way, so it's either a POST request to an email provider such as Sendgrid, or an SMTP call ( basically a network call )
So here i dont know maybe do sleep 0.5 or something. Or add a config in Recoverable which is config.send_emails_in_background or similar. if it's true, then we queue a job, if not we sleep for a fixed amount to simulate a network call
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let me know what you think, but this timing issue was reported by a pentester as well, and i was shocked about the difference between 40ms when the user does not exist, and 600ms to 1000ms when it exists.
I directly remembered that Devise NEVER sends their email in a background job ( I believe there is an open issue about that )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably this could help if it gets merged soon: #5610
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm open to making changes, my biggest concern is that the work will never get merged.
| # Perform similar work to mitigate timing attacks | ||
| # Generate a token even though we won't use it | ||
| Devise.friendly_token(20) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think what's missing is to simulate up to 50ms for an UPDATE statement that is done here:
https://github.com/heartcombo/devise/pull/5790/changes#diff-32eda6752ef9bcf49e99b0bc6be379576f4ba872944770a3ef8c66abd2d84b0bR92-R94
Summary
Problem
Devise had two email enumeration vulnerabilities:
Both vulnerabilities allow attackers to enumerate valid user emails.
Solution
Password Reset (Breaking Change - Secure by Default)
Registration (Opt-in via Paranoid Mode)
Devise.paranoid = true, duplicate registration attempts:Breaking Changes
send_reset_password_instructionsno longer returns errors for non-existent emails by defaultChanges Made
send_reset_password_instructionsinlib/devise/models/recoverable.rbPasswordsController#createto always show successRegistrationsController#createTest Plan
Security Impact
This significantly improves security by preventing email enumeration attacks, a common vulnerability used in reconnaissance phases of attacks.
🤖 Generated with Claude Code