Skip to content

Commit 302a4ff

Browse files
charettesapollo13
authored andcommitted
[3.0.x] Fixed CVE-2019-19844 -- Used verified user email for password reset requests.
Backport of 5b1fbce from master. Co-Authored-By: Florian Apolloner <florian@apolloner.eu>
1 parent 33d2cda commit 302a4ff

File tree

5 files changed

+110
-10
lines changed

5 files changed

+110
-10
lines changed

django/contrib/auth/forms.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
UserModel = get_user_model()
2121

2222

23+
def _unicode_ci_compare(s1, s2):
24+
"""
25+
Perform case-insensitive comparison of two identifiers, using the
26+
recommended algorithm from Unicode Technical Report 36, section
27+
2.11.2(B)(2).
28+
"""
29+
return unicodedata.normalize('NFKC', s1).casefold() == unicodedata.normalize('NFKC', s2).casefold()
30+
31+
2332
class ReadOnlyPasswordHashWidget(forms.Widget):
2433
template_name = 'auth/widgets/read_only_password_hash.html'
2534
read_only = True
@@ -269,11 +278,16 @@ def get_users(self, email):
269278
that prevent inactive users and users with unusable passwords from
270279
resetting their password.
271280
"""
281+
email_field_name = UserModel.get_email_field_name()
272282
active_users = UserModel._default_manager.filter(**{
273-
'%s__iexact' % UserModel.get_email_field_name(): email,
283+
'%s__iexact' % email_field_name: email,
274284
'is_active': True,
275285
})
276-
return (u for u in active_users if u.has_usable_password())
286+
return (
287+
u for u in active_users
288+
if u.has_usable_password() and
289+
_unicode_ci_compare(email, getattr(u, email_field_name))
290+
)
277291

278292
def save(self, domain_override=None,
279293
subject_template_name='registration/password_reset_subject.txt',
@@ -286,15 +300,17 @@ def save(self, domain_override=None,
286300
user.
287301
"""
288302
email = self.cleaned_data["email"]
303+
email_field_name = UserModel.get_email_field_name()
289304
for user in self.get_users(email):
290305
if not domain_override:
291306
current_site = get_current_site(request)
292307
site_name = current_site.name
293308
domain = current_site.domain
294309
else:
295310
site_name = domain = domain_override
311+
user_email = getattr(user, email_field_name)
296312
context = {
297-
'email': email,
313+
'email': user_email,
298314
'domain': domain,
299315
'site_name': site_name,
300316
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
@@ -305,7 +321,7 @@ def save(self, domain_override=None,
305321
}
306322
self.send_mail(
307323
subject_template_name, email_template_name, context, from_email,
308-
email, html_email_template_name=html_email_template_name,
324+
user_email, html_email_template_name=html_email_template_name,
309325
)
310326

311327

docs/releases/1.11.27.txt

+18-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@
22
Django 1.11.27 release notes
33
============================
44

5-
*Expected January 2, 2020*
5+
*December 18, 2019*
66

7-
Django 1.11.27 fixes a data loss bug in 1.11.26.
7+
Django 1.11.27 fixes a security issue and a data loss bug in 1.11.26.
8+
9+
CVE-2019-19844: Potential account hijack via password reset form
10+
================================================================
11+
12+
By submitting a suitably crafted email address making use of Unicode
13+
characters, that compared equal to an existing user email when lower-cased for
14+
comparison, an attacker could be sent a password reset token for the matched
15+
account.
16+
17+
In order to avoid this vulnerability, password reset requests now compare the
18+
submitted email using the stricter, recommended algorithm for case-insensitive
19+
comparison of two identifiers from `Unicode Technical Report 36, section
20+
2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
21+
sent to the email address on record rather than the submitted address.
22+
23+
.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
824

925
Bugfixes
1026
========

docs/releases/2.2.9.txt

+18-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@
22
Django 2.2.9 release notes
33
==========================
44

5-
*Expected January 2, 2020*
5+
*December 18, 2019*
66

7-
Django 2.2.9 fixes a data loss bug in 2.2.8.
7+
Django 2.2.9 fixes a security issue and a data loss bug in 2.2.8.
8+
9+
CVE-2019-19844: Potential account hijack via password reset form
10+
================================================================
11+
12+
By submitting a suitably crafted email address making use of Unicode
13+
characters, that compared equal to an existing user email when lower-cased for
14+
comparison, an attacker could be sent a password reset token for the matched
15+
account.
16+
17+
In order to avoid this vulnerability, password reset requests now compare the
18+
submitted email using the stricter, recommended algorithm for case-insensitive
19+
comparison of two identifiers from `Unicode Technical Report 36, section
20+
2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
21+
sent to the email address on record rather than the submitted address.
22+
23+
.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
824

925
Bugfixes
1026
========

docs/releases/3.0.1.txt

+18-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@
22
Django 3.0.1 release notes
33
==========================
44

5-
*Expected January 2, 2020*
5+
*December 18, 2019*
66

7-
Django 3.0.1 fixes several bugs in 3.0.
7+
Django 3.0.1 fixes a security issue and several bugs in 3.0.
8+
9+
CVE-2019-19844: Potential account hijack via password reset form
10+
================================================================
11+
12+
By submitting a suitably crafted email address making use of Unicode
13+
characters, that compared equal to an existing user email when lower-cased for
14+
comparison, an attacker could be sent a password reset token for the matched
15+
account.
16+
17+
In order to avoid this vulnerability, password reset requests now compare the
18+
submitted email using the stricter, recommended algorithm for case-insensitive
19+
comparison of two identifiers from `Unicode Technical Report 36, section
20+
2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
21+
sent to the email address on record rather than the submitted address.
22+
23+
.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
824

925
Bugfixes
1026
========

tests/auth_tests/test_forms.py

+36
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,42 @@ def test_invalid_email(self):
804804
self.assertFalse(form.is_valid())
805805
self.assertEqual(form['email'].errors, [_('Enter a valid email address.')])
806806

807+
def test_user_email_unicode_collision(self):
808+
User.objects.create_user('mike123', 'mike@example.org', 'test123')
809+
User.objects.create_user('mike456', 'mıke@example.org', 'test123')
810+
data = {'email': 'mıke@example.org'}
811+
form = PasswordResetForm(data)
812+
self.assertTrue(form.is_valid())
813+
form.save()
814+
self.assertEqual(len(mail.outbox), 1)
815+
self.assertEqual(mail.outbox[0].to, ['mıke@example.org'])
816+
817+
def test_user_email_domain_unicode_collision(self):
818+
User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
819+
User.objects.create_user('mike456', 'mike@ıxample.org', 'test123')
820+
data = {'email': 'mike@ıxample.org'}
821+
form = PasswordResetForm(data)
822+
self.assertTrue(form.is_valid())
823+
form.save()
824+
self.assertEqual(len(mail.outbox), 1)
825+
self.assertEqual(mail.outbox[0].to, ['mike@ıxample.org'])
826+
827+
def test_user_email_unicode_collision_nonexistent(self):
828+
User.objects.create_user('mike123', 'mike@example.org', 'test123')
829+
data = {'email': 'mıke@example.org'}
830+
form = PasswordResetForm(data)
831+
self.assertTrue(form.is_valid())
832+
form.save()
833+
self.assertEqual(len(mail.outbox), 0)
834+
835+
def test_user_email_domain_unicode_collision_nonexistent(self):
836+
User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
837+
data = {'email': 'mike@ıxample.org'}
838+
form = PasswordResetForm(data)
839+
self.assertTrue(form.is_valid())
840+
form.save()
841+
self.assertEqual(len(mail.outbox), 0)
842+
807843
def test_nonexistent_email(self):
808844
"""
809845
Test nonexistent email address. This should not fail because it would

0 commit comments

Comments
 (0)