VYPR
Critical severityNVD Advisory· Published Dec 18, 2019· Updated Aug 5, 2024

CVE-2019-19844

CVE-2019-19844

Description

Django before 1.11.27, 2.x before 2.2.9, and 3.x before 3.0.1 allows account takeover. A suitably crafted email address (that is equal to an existing user's email address after case transformation of Unicode characters) would allow an attacker to be sent a password reset token for the matched user account. (One mitigation in the new releases is to send password reset tokens only to the registered user email address.)

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Django password reset vulnerable to account takeover via Unicode case transformation in email addresses, allowing attackers to receive reset tokens for other users.

Root

Cause

The vulnerability resides in Django's password reset mechanism. When a user requests a password reset, the framework compares the provided email address against stored user email addresses. However, this comparison did not account for Unicode case folding, meaning that two email addresses that are visually distinct but normalize to the same string (e.g., via Unicode case mapping) were treated as different. An attacker could craft an email address that, after case transformation, matches an existing user's email address, causing the password reset token to be sent to the attacker's address instead of the legitimate user [1][3].

Exploitation

To exploit this, an attacker needs only to know the target user's email address. No authentication is required. The attacker submits a password reset request using a Unicode variant of the target's email (e.g., using a different case representation that normalizes identically). The Django application then sends the password reset token to the attacker's email address, allowing them to reset the victim's password and take over the account [4].

Impact

Successful exploitation results in complete account takeover. The attacker gains access to the victim's account, including any associated data and privileges. This vulnerability affects Django versions before 1.11.27, 2.x before 2.2.9, and 3.x before 3.0.1 [1].

Mitigation

The Django project released patched versions (1.11.27, 2.2.9, 3.0.1) that fix the comparison logic to properly handle Unicode case folding. Additionally, the fix ensures that password reset tokens are only sent to the email address registered to the account, preventing the token from being delivered to an attacker-controlled address [1][3]. Users are strongly advised to upgrade to the latest patched version.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
DjangoPyPI
< 1.11.271.11.27
DjangoPyPI
>= 2.0, < 2.2.92.2.9
DjangoPyPI
>= 3.0, < 3.0.13.0.1

Affected products

270

Patches

4
4d334bea06ca

[2.2.x] Fixed CVE-2019-19844 -- Used verified user email for password reset requests.

https://github.com/django/djangoSimon CharetteDec 17, 2019via ghsa
4 files changed · +92 8
  • django/contrib/auth/forms.py+20 4 modified
    @@ -20,6 +20,15 @@
     UserModel = get_user_model()
     
     
    +def _unicode_ci_compare(s1, s2):
    +    """
    +    Perform case-insensitive comparison of two identifiers, using the
    +    recommended algorithm from Unicode Technical Report 36, section
    +    2.11.2(B)(2).
    +    """
    +    return unicodedata.normalize('NFKC', s1).casefold() == unicodedata.normalize('NFKC', s2).casefold()
    +
    +
     class ReadOnlyPasswordHashWidget(forms.Widget):
         template_name = 'auth/widgets/read_only_password_hash.html'
         read_only = True
    @@ -256,11 +265,16 @@ def get_users(self, email):
             that prevent inactive users and users with unusable passwords from
             resetting their password.
             """
    +        email_field_name = UserModel.get_email_field_name()
             active_users = UserModel._default_manager.filter(**{
    -            '%s__iexact' % UserModel.get_email_field_name(): email,
    +            '%s__iexact' % email_field_name: email,
                 'is_active': True,
             })
    -        return (u for u in active_users if u.has_usable_password())
    +        return (
    +            u for u in active_users
    +            if u.has_usable_password() and
    +            _unicode_ci_compare(email, getattr(u, email_field_name))
    +        )
     
         def save(self, domain_override=None,
                  subject_template_name='registration/password_reset_subject.txt',
    @@ -273,15 +287,17 @@ def save(self, domain_override=None,
             user.
             """
             email = self.cleaned_data["email"]
    +        email_field_name = UserModel.get_email_field_name()
             for user in self.get_users(email):
                 if not domain_override:
                     current_site = get_current_site(request)
                     site_name = current_site.name
                     domain = current_site.domain
                 else:
                     site_name = domain = domain_override
    +            user_email = getattr(user, email_field_name)
                 context = {
    -                'email': email,
    +                'email': user_email,
                     'domain': domain,
                     'site_name': site_name,
                     'uid': urlsafe_base64_encode(force_bytes(user.pk)),
    @@ -292,7 +308,7 @@ def save(self, domain_override=None,
                 }
                 self.send_mail(
                     subject_template_name, email_template_name, context, from_email,
    -                email, html_email_template_name=html_email_template_name,
    +                user_email, html_email_template_name=html_email_template_name,
                 )
     
     
    
  • docs/releases/1.11.27.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 1.11.27 release notes
     ============================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 1.11.27 fixes a data loss bug in 1.11.26.
    +Django 1.11.27 fixes a security issue and a data loss bug in 1.11.26.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • docs/releases/2.2.9.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 2.2.9 release notes
     ==========================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 2.2.9 fixes a data loss bug in 2.2.8.
    +Django 2.2.9 fixes a security issue and a data loss bug in 2.2.8.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • tests/auth_tests/test_forms.py+36 0 modified
    @@ -754,6 +754,42 @@ def test_invalid_email(self):
             self.assertFalse(form.is_valid())
             self.assertEqual(form['email'].errors, [_('Enter a valid email address.')])
     
    +    def test_user_email_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        User.objects.create_user('mike456', 'mıke@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mıke@example.org'])
    +
    +    def test_user_email_domain_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        User.objects.create_user('mike456', 'mike@ıxample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mike@ıxample.org'])
    +
    +    def test_user_email_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
    +    def test_user_email_domain_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
         def test_nonexistent_email(self):
             """
             Test nonexistent email address. This should not fail because it would
    
302a4ff1e8b1

[3.0.x] Fixed CVE-2019-19844 -- Used verified user email for password reset requests.

https://github.com/django/djangoSimon CharetteDec 17, 2019via ghsa
5 files changed · +110 10
  • django/contrib/auth/forms.py+20 4 modified
    @@ -20,6 +20,15 @@
     UserModel = get_user_model()
     
     
    +def _unicode_ci_compare(s1, s2):
    +    """
    +    Perform case-insensitive comparison of two identifiers, using the
    +    recommended algorithm from Unicode Technical Report 36, section
    +    2.11.2(B)(2).
    +    """
    +    return unicodedata.normalize('NFKC', s1).casefold() == unicodedata.normalize('NFKC', s2).casefold()
    +
    +
     class ReadOnlyPasswordHashWidget(forms.Widget):
         template_name = 'auth/widgets/read_only_password_hash.html'
         read_only = True
    @@ -269,11 +278,16 @@ def get_users(self, email):
             that prevent inactive users and users with unusable passwords from
             resetting their password.
             """
    +        email_field_name = UserModel.get_email_field_name()
             active_users = UserModel._default_manager.filter(**{
    -            '%s__iexact' % UserModel.get_email_field_name(): email,
    +            '%s__iexact' % email_field_name: email,
                 'is_active': True,
             })
    -        return (u for u in active_users if u.has_usable_password())
    +        return (
    +            u for u in active_users
    +            if u.has_usable_password() and
    +            _unicode_ci_compare(email, getattr(u, email_field_name))
    +        )
     
         def save(self, domain_override=None,
                  subject_template_name='registration/password_reset_subject.txt',
    @@ -286,15 +300,17 @@ def save(self, domain_override=None,
             user.
             """
             email = self.cleaned_data["email"]
    +        email_field_name = UserModel.get_email_field_name()
             for user in self.get_users(email):
                 if not domain_override:
                     current_site = get_current_site(request)
                     site_name = current_site.name
                     domain = current_site.domain
                 else:
                     site_name = domain = domain_override
    +            user_email = getattr(user, email_field_name)
                 context = {
    -                'email': email,
    +                'email': user_email,
                     'domain': domain,
                     'site_name': site_name,
                     'uid': urlsafe_base64_encode(force_bytes(user.pk)),
    @@ -305,7 +321,7 @@ def save(self, domain_override=None,
                 }
                 self.send_mail(
                     subject_template_name, email_template_name, context, from_email,
    -                email, html_email_template_name=html_email_template_name,
    +                user_email, html_email_template_name=html_email_template_name,
                 )
     
     
    
  • docs/releases/1.11.27.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 1.11.27 release notes
     ============================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 1.11.27 fixes a data loss bug in 1.11.26.
    +Django 1.11.27 fixes a security issue and a data loss bug in 1.11.26.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • docs/releases/2.2.9.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 2.2.9 release notes
     ==========================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 2.2.9 fixes a data loss bug in 2.2.8.
    +Django 2.2.9 fixes a security issue and a data loss bug in 2.2.8.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • docs/releases/3.0.1.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 3.0.1 release notes
     ==========================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 3.0.1 fixes several bugs in 3.0.
    +Django 3.0.1 fixes a security issue and several bugs in 3.0.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • tests/auth_tests/test_forms.py+36 0 modified
    @@ -804,6 +804,42 @@ def test_invalid_email(self):
             self.assertFalse(form.is_valid())
             self.assertEqual(form['email'].errors, [_('Enter a valid email address.')])
     
    +    def test_user_email_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        User.objects.create_user('mike456', 'mıke@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mıke@example.org'])
    +
    +    def test_user_email_domain_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        User.objects.create_user('mike456', 'mike@ıxample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mike@ıxample.org'])
    +
    +    def test_user_email_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
    +    def test_user_email_domain_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
         def test_nonexistent_email(self):
             """
             Test nonexistent email address. This should not fail because it would
    
f4cff43bf921

[1.11.x] Fixed CVE-2019-19844 -- Used verified user email for password reset requests.

https://github.com/django/djangoSimon CharetteDec 17, 2019via ghsa
3 files changed · +86 6
  • django/contrib/auth/forms.py+26 4 modified
    @@ -16,12 +16,27 @@
     from django.template import loader
     from django.utils.encoding import force_bytes
     from django.utils.http import urlsafe_base64_encode
    +from django.utils.six import PY3
     from django.utils.text import capfirst
     from django.utils.translation import ugettext, ugettext_lazy as _
     
     UserModel = get_user_model()
     
     
    +def _unicode_ci_compare(s1, s2):
    +    """
    +    Perform case-insensitive comparison of two identifiers, using the
    +    recommended algorithm from Unicode Technical Report 36, section
    +    2.11.2(B)(2).
    +    """
    +    normalized1 = unicodedata.normalize('NFKC', s1)
    +    normalized2 = unicodedata.normalize('NFKC', s2)
    +    if PY3:
    +        return normalized1.casefold() == normalized2.casefold()
    +    # lower() is the best alternative available on Python 2.
    +    return normalized1.lower() == normalized2.lower()
    +
    +
     class ReadOnlyPasswordHashWidget(forms.Widget):
         template_name = 'auth/widgets/read_only_password_hash.html'
     
    @@ -249,11 +264,16 @@ def get_users(self, email):
             that prevent inactive users and users with unusable passwords from
             resetting their password.
             """
    +        email_field_name = UserModel.get_email_field_name()
             active_users = UserModel._default_manager.filter(**{
    -            '%s__iexact' % UserModel.get_email_field_name(): email,
    +            '%s__iexact' % email_field_name: email,
                 'is_active': True,
             })
    -        return (u for u in active_users if u.has_usable_password())
    +        return (
    +            u for u in active_users
    +            if u.has_usable_password() and
    +            _unicode_ci_compare(email, getattr(u, email_field_name))
    +        )
     
         def save(self, domain_override=None,
                  subject_template_name='registration/password_reset_subject.txt',
    @@ -266,15 +286,17 @@ def save(self, domain_override=None,
             user.
             """
             email = self.cleaned_data["email"]
    +        email_field_name = UserModel.get_email_field_name()
             for user in self.get_users(email):
                 if not domain_override:
                     current_site = get_current_site(request)
                     site_name = current_site.name
                     domain = current_site.domain
                 else:
                     site_name = domain = domain_override
    +            user_email = getattr(user, email_field_name)
                 context = {
    -                'email': email,
    +                'email': user_email,
                     'domain': domain,
                     'site_name': site_name,
                     'uid': urlsafe_base64_encode(force_bytes(user.pk)),
    @@ -286,7 +308,7 @@ def save(self, domain_override=None,
                     context.update(extra_email_context)
                 self.send_mail(
                     subject_template_name, email_template_name, context, from_email,
    -                email, html_email_template_name=html_email_template_name,
    +                user_email, html_email_template_name=html_email_template_name,
                 )
     
     
    
  • docs/releases/1.11.27.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 1.11.27 release notes
     ============================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 1.11.27 fixes a data loss bug in 1.11.26.
    +Django 1.11.27 fixes a security issue and a data loss bug in 1.11.26.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • tests/auth_tests/test_forms.py+42 0 modified
    @@ -694,6 +694,48 @@ def test_invalid_email(self):
             self.assertFalse(form.is_valid())
             self.assertEqual(form['email'].errors, [_('Enter a valid email address.')])
     
    +    def test_user_email_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        User.objects.create_user('mike456', 'mıke@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        if six.PY2:
    +            self.assertFalse(form.is_valid())
    +        else:
    +            self.assertTrue(form.is_valid())
    +            form.save()
    +            self.assertEqual(len(mail.outbox), 1)
    +            self.assertEqual(mail.outbox[0].to, ['mıke@example.org'])
    +
    +    def test_user_email_domain_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        User.objects.create_user('mike456', 'mike@ıxample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mike@ıxample.org'])
    +
    +    def test_user_email_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        if six.PY2:
    +            self.assertFalse(form.is_valid())
    +        else:
    +            self.assertTrue(form.is_valid())
    +            form.save()
    +            self.assertEqual(len(mail.outbox), 0)
    +
    +    def test_user_email_domain_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
         def test_nonexistent_email(self):
             """
             Test nonexistent email address. This should not fail because it would
    
5b1fbcef7a8b

Fixed CVE-2019-19844 -- Used verified user email for password reset requests.

https://github.com/django/djangoSimon CharetteDec 17, 2019via ghsa
5 files changed · +110 10
  • django/contrib/auth/forms.py+20 4 modified
    @@ -20,6 +20,15 @@
     UserModel = get_user_model()
     
     
    +def _unicode_ci_compare(s1, s2):
    +    """
    +    Perform case-insensitive comparison of two identifiers, using the
    +    recommended algorithm from Unicode Technical Report 36, section
    +    2.11.2(B)(2).
    +    """
    +    return unicodedata.normalize('NFKC', s1).casefold() == unicodedata.normalize('NFKC', s2).casefold()
    +
    +
     class ReadOnlyPasswordHashWidget(forms.Widget):
         template_name = 'auth/widgets/read_only_password_hash.html'
         read_only = True
    @@ -269,11 +278,16 @@ def get_users(self, email):
             that prevent inactive users and users with unusable passwords from
             resetting their password.
             """
    +        email_field_name = UserModel.get_email_field_name()
             active_users = UserModel._default_manager.filter(**{
    -            '%s__iexact' % UserModel.get_email_field_name(): email,
    +            '%s__iexact' % email_field_name: email,
                 'is_active': True,
             })
    -        return (u for u in active_users if u.has_usable_password())
    +        return (
    +            u for u in active_users
    +            if u.has_usable_password() and
    +            _unicode_ci_compare(email, getattr(u, email_field_name))
    +        )
     
         def save(self, domain_override=None,
                  subject_template_name='registration/password_reset_subject.txt',
    @@ -292,9 +306,11 @@ def save(self, domain_override=None,
                 domain = current_site.domain
             else:
                 site_name = domain = domain_override
    +        email_field_name = UserModel.get_email_field_name()
             for user in self.get_users(email):
    +            user_email = getattr(user, email_field_name)
                 context = {
    -                'email': email,
    +                'email': user_email,
                     'domain': domain,
                     'site_name': site_name,
                     'uid': urlsafe_base64_encode(force_bytes(user.pk)),
    @@ -305,7 +321,7 @@ def save(self, domain_override=None,
                 }
                 self.send_mail(
                     subject_template_name, email_template_name, context, from_email,
    -                email, html_email_template_name=html_email_template_name,
    +                user_email, html_email_template_name=html_email_template_name,
                 )
     
     
    
  • docs/releases/1.11.27.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 1.11.27 release notes
     ============================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 1.11.27 fixes a data loss bug in 1.11.26.
    +Django 1.11.27 fixes a security issue and a data loss bug in 1.11.26.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • docs/releases/2.2.9.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 2.2.9 release notes
     ==========================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 2.2.9 fixes a data loss bug in 2.2.8.
    +Django 2.2.9 fixes a security issue and a data loss bug in 2.2.8.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • docs/releases/3.0.1.txt+18 2 modified
    @@ -2,9 +2,25 @@
     Django 3.0.1 release notes
     ==========================
     
    -*Expected January 2, 2020*
    +*December 18, 2019*
     
    -Django 3.0.1 fixes several bugs in 3.0.
    +Django 3.0.1 fixes a security issue and several bugs in 3.0.
    +
    +CVE-2019-19844: Potential account hijack via password reset form
    +================================================================
    +
    +By submitting a suitably crafted email address making use of Unicode
    +characters, that compared equal to an existing user email when lower-cased for
    +comparison, an attacker could be sent a password reset token for the matched
    +account.
    +
    +In order to avoid this vulnerability, password reset requests now compare the
    +submitted email using the stricter, recommended algorithm for case-insensitive
    +comparison of two identifiers from `Unicode Technical Report 36, section
    +2.11.2(B)(2)`__. Upon a match, the email containing the reset token will be
    +sent to the email address on record rather than the submitted address.
    +
    +.. __: https://www.unicode.org/reports/tr36/#Recommendations_General
     
     Bugfixes
     ========
    
  • tests/auth_tests/test_forms.py+36 0 modified
    @@ -804,6 +804,42 @@ def test_invalid_email(self):
             self.assertFalse(form.is_valid())
             self.assertEqual(form['email'].errors, [_('Enter a valid email address.')])
     
    +    def test_user_email_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        User.objects.create_user('mike456', 'mıke@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mıke@example.org'])
    +
    +    def test_user_email_domain_unicode_collision(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        User.objects.create_user('mike456', 'mike@ıxample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 1)
    +        self.assertEqual(mail.outbox[0].to, ['mike@ıxample.org'])
    +
    +    def test_user_email_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@example.org', 'test123')
    +        data = {'email': 'mıke@example.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
    +    def test_user_email_domain_unicode_collision_nonexistent(self):
    +        User.objects.create_user('mike123', 'mike@ixample.org', 'test123')
    +        data = {'email': 'mike@ıxample.org'}
    +        form = PasswordResetForm(data)
    +        self.assertTrue(form.is_valid())
    +        form.save()
    +        self.assertEqual(len(mail.outbox), 0)
    +
         def test_nonexistent_email(self):
             """
             Test nonexistent email address. This should not fail because it would
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

23

News mentions

0

No linked articles in our index yet.