Skip to content

Commit 9f1432a

Browse files
committed
Prevent reuse of confirmation token.
Closes #86. The issue is that signed tokens have a simple payload by default that doesn't verify anything other than the record id and the token's purpose. This can lead to a security challenge as the token can be used to confirm anything that has the same "purpose" for that record. An example would be someone changing their email address to an email address that they don't control. Using one account, they change the email to an email address that they control and get the confirmation token. They then change the email to one that they can't access and use the token from the first request to "confirm" the second request. The tokens can be used any number of times as long as they're used before expiration. With this change, the email address is included as plain text in the request, as well as being used as part of the "purpose" in the token. The second request fails because the plain text email address is used to constrain the signed lookup. If they change the plain text email address in the link then the message will fail to be validated as the "purpose" won't match. Either way, the token is usable only for confirming the original email from the token's creation.
1 parent 2e6e5b0 commit 9f1432a

File tree

7 files changed

+54
-14
lines changed

7 files changed

+54
-14
lines changed

app/controllers/confirmations_controller.rb

+10-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@ def create
1313
end
1414

1515
def edit
16-
@user = User.find_signed(params[:confirmation_token], purpose: :confirm_email)
16+
expected_email = params[:email].strip.downcase
17+
18+
# It's possible that the email address that we're confirming is the
19+
# primary "email" field or the secondary "unconfirmed_email" field.
20+
# We need to check both fields to find the user, but will only check
21+
# the primary field if the secondary field is null.
22+
@user = (User.where(unconfirmed_email: expected_email).
23+
or(User.where(email: expected_email, unconfirmed_email: nil))).
24+
find_signed(params[:confirmation_token],
25+
purpose: User.email_confirmation_purpose_for(expected_email))
1726
if @user.present? && @user.unconfirmed_or_reconfirming?
1827
if @user.confirm!
1928
login @user

app/mailers/user_mailer.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ class UserMailer < ApplicationMailer
66
#
77
# en.user_mailer.confirmation.subject
88
#
9-
def confirmation(user, confirmation_token)
9+
def confirmation(user, confirmation_token, confirmable_email)
1010
@user = user
1111
@confirmation_token = confirmation_token
12+
@confirmable_email = confirmable_email
1213

1314
mail to: @user.confirmable_email, subject: "Confirmation Instructions"
1415
end

app/models/user.rb

+11-2
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,17 @@ def confirmable_email
5353
end
5454
end
5555

56+
def self.email_confirmation_purpose_for(email)
57+
"confirm_email: #{email}"
58+
end
59+
60+
def email_confirmation_purpose
61+
self.class.email_confirmation_purpose_for(confirmable_email)
62+
end
63+
5664
def generate_confirmation_token
57-
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION, purpose: :confirm_email
65+
signed_id expires_in: CONFIRMATION_TOKEN_EXPIRATION,
66+
purpose: email_confirmation_purpose
5867
end
5968

6069
def generate_password_reset_token
@@ -63,7 +72,7 @@ def generate_password_reset_token
6372

6473
def send_confirmation_email!
6574
confirmation_token = generate_confirmation_token
66-
UserMailer.confirmation(self, confirmation_token).deliver_now
75+
UserMailer.confirmation(self, confirmation_token, confirmable_email).deliver_now
6776
end
6877

6978
def send_password_reset_email!
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<h1>Confirmation Instructions</h1>
22

3-
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token) %>
3+
<%= link_to "Click here to confirm your email.", edit_confirmation_url(@confirmation_token, email: @confirmable_email) %>
+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
Confirmation Instructions
22

3-
<%= edit_confirmation_url(@confirmation_token) %>
3+
<%= edit_confirmation_url(@confirmation_token, email: @confirmable_email) %>

test/controllers/confirmations_controller_test.rb

+28-7
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
1111
freeze_time do
1212
confirmation_token = @unconfirmed_user.generate_confirmation_token
1313

14-
get edit_confirmation_path(confirmation_token)
14+
get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.confirmable_email)
1515

1616
assert @unconfirmed_user.reload.confirmed?
1717
assert_equal Time.now, @unconfirmed_user.confirmed_at
@@ -26,7 +26,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
2626
freeze_time do
2727
confirmation_token = @reconfirmed_user.generate_confirmation_token
2828

29-
get edit_confirmation_path(confirmation_token)
29+
get edit_confirmation_path(confirmation_token, email: unconfirmed_email)
3030

3131
assert @reconfirmed_user.reload.confirmed?
3232
assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
@@ -44,7 +44,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
4444
freeze_time do
4545
confirmation_token = @reconfirmed_user.generate_confirmation_token
4646

47-
get edit_confirmation_path(confirmation_token)
47+
get edit_confirmation_path(confirmation_token, email: @confirmed_user.confirmable_email)
4848

4949
assert_equal original_email, @reconfirmed_user.reload.email
5050
assert_redirected_to new_confirmation_path
@@ -56,7 +56,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
5656
confirmation_token = @unconfirmed_user.generate_confirmation_token
5757

5858
travel_to 601.seconds.from_now do
59-
get edit_confirmation_path(confirmation_token)
59+
get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.confirmable_email)
6060

6161
assert_nil @unconfirmed_user.reload.confirmed_at
6262
assert_not @unconfirmed_user.reload.confirmed?
@@ -66,7 +66,15 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
6666
end
6767

6868
test "should redirect if confirmation link is incorrect" do
69-
get edit_confirmation_path("not_a_real_token")
69+
get edit_confirmation_path("not_a_real_token", email: @unconfirmed_user.confirmable_email)
70+
assert_redirected_to new_confirmation_path
71+
assert_not_nil flash[:alert]
72+
end
73+
74+
test "should redirect if confirmation email is incorrect" do
75+
confirmation_token = @unconfirmed_user.generate_confirmation_token
76+
77+
get edit_confirmation_path(confirmation_token, email: "not_the_email@gmail.com")
7078
assert_redirected_to new_confirmation_path
7179
assert_not_nil flash[:alert]
7280
end
@@ -99,7 +107,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
99107

100108
login @confirmed_user
101109

102-
get edit_confirmation_path(confirmation_token)
110+
get edit_confirmation_path(confirmation_token, email: @confirmed_user.confirmable_email)
103111

104112
assert_not_equal Time.current, @confirmed_user.reload.confirmed_at
105113
assert_redirected_to new_confirmation_path
@@ -115,7 +123,7 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
115123

116124
confirmation_token = @reconfirmed_user.generate_confirmation_token
117125

118-
get edit_confirmation_path(confirmation_token)
126+
get edit_confirmation_path(confirmation_token, email: unconfirmed_email)
119127

120128
assert_equal Time.current, @reconfirmed_user.reload.confirmed_at
121129
assert @reconfirmed_user.reload.confirmed?
@@ -126,6 +134,19 @@ class ConfirmationsControllerTest < ActionDispatch::IntegrationTest
126134
end
127135
end
128136

137+
test "should prevent reuse of confirmation token" do
138+
login @unconfirmed_user
139+
140+
confirmation_token = @unconfirmed_user.generate_confirmation_token
141+
142+
get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.confirmable_email)
143+
assert @unconfirmed_user.reload.confirmed?
144+
@unconfirmed_user.update(unconfirmed_email: "not_the_same@example.com")
145+
146+
get edit_confirmation_path(confirmation_token, email: @unconfirmed_user.unconfirmed_email)
147+
assert_redirected_to new_confirmation_path
148+
end
149+
129150
test "should prevent authenticated user from submitting the confirmation form" do
130151
login @confirmed_user
131152

test/mailers/user_mailer_test.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class UserMailerTest < ActionMailer::TestCase
77

88
test "confirmation" do
99
confirmation_token = @user.generate_confirmation_token
10-
mail = UserMailer.confirmation(@user, confirmation_token)
10+
mail = UserMailer.confirmation(@user, confirmation_token, @user.unconfirmed_email)
1111
assert_equal "Confirmation Instructions", mail.subject
1212
assert_equal [@user.email], mail.to
1313
assert_equal [User::MAILER_FROM_EMAIL], mail.from

0 commit comments

Comments
 (0)