Skip to content

Commit 0252777

Browse files
Fix race condition vulnerability, by ensuring the unconfirmed_email is always saved (#5784)
Fix security issue in the `Confirmable` "change email" flow, where a user can end up confirming an email address that they have no access to. The flow for this is: 1. Attacker registers `attacker1@email.com` 2. Attacker changes their email to `attacker2@email.com`, but does not yet confirm this 3. Attacker submits two concurrent "change email" requests a. one changing to `attacker2@email.com` b. one changing to `victim@email.com` When request 3.a is run, the `Confirmable.postpone_email_change_until_confirmation_and_regenerate_confirmation_token` method sets both the `unconfirmed_email` and `confirmation_token` properties. But as the `unconfirmed_email` value is the same as the model already had from step 2, this attribute is not included in the SQL `UPDATE` statement. The SQL `UPDATE` statement only updates the `confirmation_token`. This token is emailed to the `attacker2@email.com` address. If the "victim" race request (3.b) completes first, it will update both the `unconfirmed_email` and the `confirmation_token`. But then request 3.a will replace just the token. The model's end state is having the confirmation token that was sent to the attacker, but with the `unconfirmed_email` of the victim. When the attacker follows the confirmation link, they will have confirmed the victim's email address, on an account that the attacker controls. Co-authored-by: Carlos Antonio da Silva <carlosantoniodasilva@gmail.com>
1 parent 879f79f commit 0252777

File tree

4 files changed

+47
-1
lines changed

4 files changed

+47
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### Unreleased
2+
3+
* security fixes
4+
* Fix race condition vulnerability on confirmable "change email" which would allow confirming an email they don't own [#5783](https://github.com/heartcombo/devise/pull/5783) [#5784](https://github.com/heartcombo/devise/pull/5784)
5+
16
### 5.0.2 - 2026-02-18
27

38
* enhancements

lib/devise/models/confirmable.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,11 @@ def generate_confirmation_token!
258258
generate_confirmation_token && save(validate: false)
259259
end
260260

261-
262261
def postpone_email_change_until_confirmation_and_regenerate_confirmation_token
263262
@reconfirmation_required = true
263+
# Force unconfirmed_email to be updated, even if the value hasn't changed, to prevent a
264+
# race condition which could allow an attacker to confirm an email they don't own. See #5783.
265+
devise_unconfirmed_email_will_change!
264266
self.unconfirmed_email = self.email
265267
self.email = self.devise_email_in_database
266268
self.confirmation_token = nil

lib/devise/orm.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ def devise_will_save_change_to_email?
3535
will_save_change_to_email?
3636
end
3737

38+
def devise_unconfirmed_email_will_change!
39+
unconfirmed_email_will_change!
40+
end
41+
3842
def devise_respond_to_and_will_save_change_to_attribute?(attribute)
3943
respond_to?("will_save_change_to_#{attribute}?") && send("will_save_change_to_#{attribute}?")
4044
end
@@ -61,6 +65,13 @@ def devise_will_save_change_to_email?
6165
email_changed?
6266
end
6367

68+
def devise_unconfirmed_email_will_change!
69+
# Mongoid's will_change! doesn't force unchanged attributes into updates,
70+
# so we override changed_attributes to make it see a difference.
71+
unconfirmed_email_will_change!
72+
changed_attributes["unconfirmed_email"] = nil
73+
end
74+
6475
def devise_respond_to_and_will_save_change_to_attribute?(attribute)
6576
respond_to?("#{attribute}_changed?") && send("#{attribute}_changed?")
6677
end

test/integration/confirmable_test.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,4 +354,32 @@ def visit_admin_confirmation_with_token(confirmation_token)
354354
assert_contain(/Email.*already.*taken/)
355355
assert admin.reload.pending_reconfirmation?
356356
end
357+
358+
test 'concurrent "update email" requests should not allow confirming a victim email address' do
359+
attacker_email = "attacker@example.com"
360+
victim_email = "victim@example.com"
361+
362+
attacker = create_admin
363+
# update the email address of the attacker, but do not confirm it yet
364+
attacker.update!(email: attacker_email)
365+
366+
# A new request starts, to update the unconfirmed email again.
367+
attacker = Admin.find_by(id: attacker.id)
368+
369+
# A concurrent request also updates the email address to the victim, while the `attacker` request's model is in memory
370+
Admin.where(id: attacker.id).update_all(
371+
unconfirmed_email: victim_email,
372+
confirmation_token: "different token"
373+
)
374+
375+
# Now the attacker updates to the same prior unconfirmed email address, and confirm.
376+
# This should update the `unconfirmed_email` in the database, even though it is unchanged from the models point of view.
377+
attacker.update!(email: attacker_email)
378+
attacker_token = attacker.raw_confirmation_token
379+
visit_admin_confirmation_with_token(attacker_token)
380+
381+
attacker.reload
382+
assert attacker.confirmed?
383+
assert_equal attacker_email, attacker.email
384+
end
357385
end

0 commit comments

Comments
 (0)