Skip to content
Snippets Groups Projects
Commit 44deb06e authored by Aboobacker MK's avatar Aboobacker MK Committed by Ahmad Tolba
Browse files

User password reset accepts multiple email addresses

Merge branch 'security-dblessing_password_reset-16-1' into '16-1-stable-ee'

See merge request gitlab-org/security/gitlab!3798

Changelog: security
parent a2e99df3
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -47,7 +47,7 @@ rails-production-server-boot-puma-cng:
extends:
- .rails-production-server-boot
script:
- curl --silent https://gitlab.com/gitlab-org/build/CNG/-/raw/master/gitlab-webservice/configuration/puma.rb > config/puma.rb
- curl --silent https://gitlab.com/gitlab-org/build/CNG/-/raw/16-1-stable/gitlab-webservice/configuration/puma.rb > config/puma.rb
- sed --in-place "s:/srv/gitlab:${PWD}:" config/puma.rb
- bundle exec puma --environment production --config config/puma.rb &
- sleep 40 # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114124#note_1309506358
Loading
Loading
# frozen_string_literal: true
 
# Concern that overrides the Devise methods
# to send reset password instructions to any verified user email
# Concern that overrides the Devise methods to allow reset password instructions
# to be sent to any users' confirmed secondary emails.
# See https://github.com/heartcombo/devise/blob/main/lib/devise/models/recoverable.rb
module RecoverableByAnyEmail
extend ActiveSupport::Concern
 
class_methods do
def send_reset_password_instructions(attributes = {})
email = attributes.delete(:email)
super unless email
return super unless attributes[:email]
 
recoverable = by_email_with_errors(email)
recoverable.send_reset_password_instructions(to: email) if recoverable&.persisted?
recoverable
end
email = Email.confirmed.find_by(email: attributes[:email].to_s)
return super unless email
 
private
recoverable = email.user
 
def by_email_with_errors(email)
record = find_by_any_email(email, confirmed: true) || new
record.errors.add(:email, :invalid) unless record.persisted?
record
recoverable.send_reset_password_instructions(to: email.email)
recoverable
end
end
 
def send_reset_password_instructions(opts = {})
token = set_reset_password_token
send_reset_password_instructions_notification(token, opts)
 
token
end
 
private
protected
 
def send_reset_password_instructions_notification(token, opts = {})
send_devise_notification(:reset_password_instructions, token, opts)
Loading
Loading
Loading
Loading
@@ -7,7 +7,7 @@
= f.label :email, _('Email')
= f.email_field :email, class: "form-control gl-form-input", required: true, autocomplete: 'off', value: params[:user_email], autofocus: true, title: _('Please provide a valid email address.')
.form-text.text-muted
= _('Requires a verified GitLab email address.')
= _('Requires your primary or verified secondary GitLab email address.')
 
- if recaptcha_enabled?
.gl-mb-5
Loading
Loading
Loading
Loading
@@ -3,9 +3,8 @@
require_relative '../lib/gitlab_settings'
 
file = ENV.fetch('GITLAB_CONFIG') { Rails.root.join('config/gitlab.yml') }
section = ENV.fetch('GITLAB_ENV') { Rails.env }
 
Settings = GitlabSettings.load(file, section) do
Settings = GitlabSettings.load(file, Rails.env) do
def gitlab_on_standard_port?
on_standard_port?(gitlab)
end
Loading
Loading
Loading
Loading
@@ -39004,15 +39004,15 @@ msgid_plural "Requires %{count} approvals from %{names}."
msgstr[0] ""
msgstr[1] ""
 
msgid "Requires a verified GitLab email address."
msgstr ""
msgid "Requires you to deploy or set up cloud-hosted Sentry."
msgstr ""
 
msgid "Requires your primary GitLab email address."
msgstr ""
 
msgid "Requires your primary or verified secondary GitLab email address."
msgstr ""
msgid "Resend"
msgstr ""
 
Loading
Loading
@@ -2,7 +2,7 @@
 
require 'spec_helper'
 
RSpec.describe PasswordsController do
RSpec.describe PasswordsController, feature_category: :system_access do
include DeviseHelpers
 
before do
Loading
Loading
@@ -109,8 +109,9 @@
 
describe '#create' do
let(:user) { create(:user) }
let(:email) { user.email }
 
subject(:perform_request) { post(:create, params: { user: { email: user.email } }) }
subject(:perform_request) { post(:create, params: { user: { email: email } }) }
 
context 'when reCAPTCHA is disabled' do
before do
Loading
Loading
@@ -161,5 +162,131 @@
expect(flash[:notice]).to include 'If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes.'
end
end
context "sending 'Reset password instructions' email" do
include EmailHelpers
let_it_be(:user) { create(:user) }
let_it_be(:user_confirmed_primary_email) { user.email }
let_it_be(:user_confirmed_secondary_email) { create(:email, :confirmed, user: user, email: 'confirmed-secondary-email@example.com').email }
let_it_be(:user_unconfirmed_secondary_email) { create(:email, user: user, email: 'unconfirmed-secondary-email@example.com').email }
let_it_be(:unknown_email) { 'attacker@example.com' }
let_it_be(:invalid_email) { 'invalid_email' }
let_it_be(:sql_injection_email) { 'sql-injection-email@example.com OR 1=1' }
let_it_be(:another_user_confirmed_primary_email) { create(:user).email }
let_it_be(:another_user_unconfirmed_primary_email) { create(:user, :unconfirmed).email }
before do
reset_delivered_emails!
perform_request
perform_enqueued_jobs
end
context "when email param matches user's confirmed primary email" do
let(:email) { user_confirmed_primary_email }
it 'sends email to the primary email only' do
expect_only_one_email_to_be_sent(subject: 'Reset password instructions', to: [user_confirmed_primary_email])
end
end
context "when email param matches user's unconfirmed primary email" do
let(:email) { another_user_unconfirmed_primary_email }
# By default 'devise' gem allows password reset by unconfirmed primary email.
# When user account with unconfirmed primary email that means it is unconfirmed.
#
# Password reset by unconfirmed primary email is very helpful from
# security perspective. Example:
# Malicious person creates user account on GitLab with someone's email.
# If the email owner confirms the email for newly created account, the malicious person will be able
# to sign in into the account by password they provided during account signup.
# The malicious person could set up 2FA to the user account, after that
# te email owner would not able to get access to that user account even
# after performing password reset.
# To deal with that case safely the email owner should reset password
# for the user account first. That will make sure that after the user account
# is confirmed the malicious person is not be able to sign in with
# the password they provided during the account signup. Then email owner
# could sign into the account, they will see a prompt to confirm the account email
# to proceed. They can safely confirm the email and take over the account.
# That is one of the reasons why password reset by unconfirmed primary email should be allowed.
it 'sends email to the primary email only' do
expect_only_one_email_to_be_sent(subject: 'Reset password instructions', to: [another_user_unconfirmed_primary_email])
end
end
context "when email param matches user's confirmed secondary email" do
let(:email) { user_confirmed_secondary_email }
it 'sends email to the confirmed secondary email only' do
expect_only_one_email_to_be_sent(subject: 'Reset password instructions', to: [user_confirmed_secondary_email])
end
end
# While unconfirmed primary emails are linked with users accounts,
# unconfirmed secondary emails should not be linked with any users till they are confirmed
# See https://gitlab.com/gitlab-org/gitlab/-/issues/356665
#
# In https://gitlab.com/gitlab-org/gitlab/-/issues/367823, it is considerd
# to prevent reserving emails on Gitlab by unconfirmed secondary emails.
# As per this issue, there might be cases that there are multiple users
# with the same unconfirmed secondary emails. It would be impossible to identify for
# what user account password reset is requested if password reset were allowed
# by unconfirmed secondary emails.
# Also note that it is not possible to request email confirmation for
# unconfirmed secondary emails without having access to the user account.
context "when email param matches user's unconfirmed secondary email" do
let(:email) { user_unconfirmed_secondary_email }
it 'does not send email to anyone' do
should_not_email_anyone
end
end
context 'when email param is unknown email' do
let(:email) { unknown_email }
it 'does not send email to anyone' do
should_not_email_anyone
end
end
context 'when email param is invalid email' do
let(:email) { invalid_email }
it 'does not send email to anyone' do
should_not_email_anyone
end
end
context 'when email param with attempt to cause SQL injection' do
let(:email) { sql_injection_email }
it 'does not send email to anyone' do
should_not_email_anyone
end
end
# See https://gitlab.com/gitlab-org/gitlab/-/issues/436084
context 'when email param with multiple emails' do
let(:email) do
[
user_confirmed_primary_email,
user_confirmed_secondary_email,
user_unconfirmed_secondary_email,
unknown_email,
another_user_confirmed_primary_email,
another_user_unconfirmed_primary_email
]
end
it 'does not send email to anyone' do
should_not_email_anyone
end
end
end
end
end
Loading
Loading
@@ -8,7 +8,6 @@
 
require_relative '../config/bundler_setup'
 
ENV['GITLAB_ENV'] = 'test'
ENV['IN_MEMORY_APPLICATION_SETTINGS'] = 'true'
 
require './spec/deprecation_warnings'
Loading
Loading
Loading
Loading
@@ -103,10 +103,10 @@
 
describe '#reset_password_instructions' do
let_it_be(:user) { create(:user) }
let(:params) { {} }
let(:opts) { {} }
 
subject do
described_class.reset_password_instructions(user, 'faketoken', params)
described_class.reset_password_instructions(user, 'faketoken', opts)
end
 
it_behaves_like 'an email sent from GitLab'
Loading
Loading
@@ -139,9 +139,9 @@
is_expected.to have_header 'X-Mailgun-Suppressions-Bypass', 'true'
end
 
context 'with email in params' do
context 'with email in opts' do
let(:email) { 'example@example.com' }
let(:params) { { to: email } }
let(:opts) { { to: email } }
 
it 'is sent to the specified email' do
is_expected.to deliver_to email
Loading
Loading
Loading
Loading
@@ -4,79 +4,140 @@
 
RSpec.describe RecoverableByAnyEmail, feature_category: :system_access do
describe '.send_reset_password_instructions' do
let_it_be(:user) { create(:user, email: 'test@example.com') }
let_it_be(:verified_email) { create(:email, :confirmed, user: user) }
let_it_be(:unverified_email) { create(:email, user: user) }
include EmailHelpers
 
subject(:send_reset_password_instructions) do
User.send_reset_password_instructions(email: email)
end
 
shared_examples 'sends the password reset email' do
let_it_be(:user) { create(:user) }
let_it_be(:user_confirmed_primary_email) { user.email }
let_it_be(:user_confirmed_secondary_email) do
create(:email, :confirmed, user: user, email: 'confirmed-secondary-email@example.com').email
end
let_it_be(:user_unconfirmed_secondary_email) do
create(:email, user: user, email: 'unconfirmed-secondary-email@example.com').email
end
let_it_be(:unknown_email) { 'attacker@example.com' }
let_it_be(:invalid_email) { 'invalid_email' }
let_it_be(:sql_injection_email) { 'sql-injection-email@example.com OR 1=1' }
let_it_be(:another_user_confirmed_primary_email) { create(:user).email }
let_it_be(:another_user) { create(:user, :unconfirmed) }
let_it_be(:another_user_unconfirmed_primary_email) { another_user.email }
shared_examples "sends 'Reset password instructions' email" do
it 'finds the user' do
expect(send_reset_password_instructions).to eq(user)
expect(send_reset_password_instructions).to eq(expected_user)
end
 
it 'sends the email' do
reset_delivered_emails!
expect { send_reset_password_instructions }.to have_enqueued_mail(DeviseMailer, :reset_password_instructions)
perform_enqueued_jobs
expect_only_one_email_to_be_sent(subject: 'Reset password instructions', to: [email])
end
end
 
shared_examples 'does not send the password reset email' do
shared_examples "does not send 'Reset password instructions' email" do
# If user is not found, returns a new user with errors.
# See https://github.com/heartcombo/devise/blob/main/lib/devise/models/recoverable.rb
it 'does not find the user' do
expect(subject.id).to be_nil
expect(subject.errors).not_to be_empty
expect(send_reset_password_instructions).to be_instance_of User
expect(send_reset_password_instructions).to be_new_record
expect(send_reset_password_instructions.errors).not_to be_empty
end
 
it 'does not send any email' do
subject
it 'does not send email to anyone' do
reset_delivered_emails!
expect { send_reset_password_instructions }
.not_to have_enqueued_mail(DeviseMailer, :reset_password_instructions)
perform_enqueued_jobs
 
expect { subject }.not_to have_enqueued_mail(DeviseMailer, :reset_password_instructions)
should_not_email_anyone
end
end
 
context 'with user primary email' do
let(:email) { user.email }
context "when email param matches user's confirmed primary email" do
let(:expected_user) { user }
let(:email) { user_confirmed_primary_email }
 
it_behaves_like 'sends the password reset email'
it_behaves_like "sends 'Reset password instructions' email"
end
 
context 'with user verified email' do
let(:email) { verified_email.email }
context "when email param matches user's unconfirmed primary email" do
let(:expected_user) { another_user }
let(:email) { another_user_unconfirmed_primary_email }
 
it_behaves_like 'sends the password reset email'
it_behaves_like "sends 'Reset password instructions' email"
end
 
context 'with user unverified email' do
let(:email) { unverified_email.email }
context "when email param matches user's confirmed secondary email" do
let(:expected_user) { user }
let(:email) { user_confirmed_secondary_email }
 
it_behaves_like 'does not send the password reset email'
it_behaves_like "sends 'Reset password instructions' email"
end
end
 
describe '#send_reset_password_instructions' do
let_it_be(:user) { create(:user) }
let_it_be(:opts) { { email: 'random@email.com' } }
let_it_be(:token) { 'passwordresettoken' }
context "when email param matches user's unconfirmed secondary email" do
let(:email) { user_unconfirmed_secondary_email }
 
before do
allow(user).to receive(:set_reset_password_token).and_return(token)
it_behaves_like "does not send 'Reset password instructions' email"
end
 
subject { user.send_reset_password_instructions(opts) }
context 'when email param is unknown email' do
let(:email) { unknown_email }
 
it 'sends the email' do
expect { subject }.to have_enqueued_mail(DeviseMailer, :reset_password_instructions)
it_behaves_like "does not send 'Reset password instructions' email"
end
 
it 'calls send_reset_password_instructions_notification with correct arguments' do
expect(user).to receive(:send_reset_password_instructions_notification).with(token, opts)
context 'when email param is invalid email' do
let(:email) { invalid_email }
 
subject
it_behaves_like "does not send 'Reset password instructions' email"
end
 
it 'returns the generated token' do
expect(subject).to eq(token)
context 'when email param with attempt to cause SQL injection' do
let(:email) { sql_injection_email }
it_behaves_like "does not send 'Reset password instructions' email"
end
context 'when email param is nil' do
let(:email) { nil }
it_behaves_like "does not send 'Reset password instructions' email"
end
context 'when email param is empty string' do
let(:email) { '' }
it_behaves_like "does not send 'Reset password instructions' email"
end
# See https://gitlab.com/gitlab-org/gitlab/-/issues/436084
context 'when email param with multiple emails' do
let(:email) do
[
user_confirmed_primary_email,
user_confirmed_secondary_email,
user_unconfirmed_secondary_email,
unknown_email,
another_user_confirmed_primary_email,
another_user_unconfirmed_primary_email
]
end
it_behaves_like "does not send 'Reset password instructions' email"
end
end
end
Loading
Loading
@@ -14,6 +14,18 @@ def reset_delivered_emails!
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
end
 
def expect_only_one_email_to_be_sent(subject:, to:)
count_of_sent_emails = ActionMailer::Base.deliveries.count
expect(count_of_sent_emails).to eq(1), "Expected only one email to be sent, but #{count_of_sent_emails} emails were sent instead"
return unless count_of_sent_emails == 1
message = ActionMailer::Base.deliveries.first
expect(message.subject).to eq(subject), "Expected '#{subject}' email to be sent, but '#{message.subject}' email was sent instead"
expect(message.to).to match_array(to), "Expected the email to be sent to #{to}, but it was sent to #{message.to} instead"
end
def should_only_email(*users, kind: :to)
recipients = email_recipients(kind: kind)
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment