From 3eb814e02a4c336866d4189fa0c24fd1875863ed Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Wed, 19 Nov 2025 16:52:28 +0000 Subject: [PATCH] Fixed CVE-2025-13473 -- Standardized timing of check_password() in mod_wsgi auth handler. Refs CVE-2024-39329, #20760. Thanks Stackered for the report, and Jacob Walls and Markus Holtermann for the reviews. Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> --- django/contrib/auth/handlers/modwsgi.py | 37 ++++++++++++++++++++----- docs/releases/4.2.28.txt | 10 +++++++ docs/releases/5.2.11.txt | 10 +++++++ docs/releases/6.0.2.txt | 10 +++++++ tests/auth_tests/test_handlers.py | 28 +++++++++++++++++++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/django/contrib/auth/handlers/modwsgi.py b/django/contrib/auth/handlers/modwsgi.py index 591ec72cb4..086db89fc8 100644 --- a/django/contrib/auth/handlers/modwsgi.py +++ b/django/contrib/auth/handlers/modwsgi.py @@ -4,24 +4,47 @@ from django.contrib import auth UserModel = auth.get_user_model() +def _get_user(username): + """ + Return the UserModel instance for `username`. + + If no matching user exists, or if the user is inactive, return None, in + which case the default password hasher is run to mitigate timing attacks. + """ + try: + user = UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + user = None + else: + if not user.is_active: + user = None + + if user is None: + # Run the default password hasher once to reduce the timing difference + # between existing/active and nonexistent/inactive users (#20760). + UserModel().set_password("") + + return user + + def check_password(environ, username, password): """ Authenticate against Django's auth database. mod_wsgi docs specify None, True, False as return value depending on whether the user exists and authenticates. + + Return None if the user does not exist, return False if the user exists but + password is not correct, and return True otherwise. + """ # db connection state is managed similarly to the wsgi handler # as mod_wsgi may call these functions outside of a request/response cycle db.reset_queries() try: - try: - user = UserModel._default_manager.get_by_natural_key(username) - except UserModel.DoesNotExist: - return None - if not user.is_active: - return None - return user.check_password(password) + user = _get_user(username) + if user: + return user.check_password(password) finally: db.close_old_connections() diff --git a/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index 8c6d4a2a1d..9f6d5cb152 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -7,3 +7,13 @@ Django 4.2.28 release notes Django 4.2.28 fixes three security issues with severity "high", two security issues with severity "moderate", and one security issue with severity "low" in 4.2.27. + +CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler +================================================================================================= + +The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for +:doc:`authentication via mod_wsgi` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index 545a7aeb70..f975e45166 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -7,3 +7,13 @@ Django 5.2.11 release notes Django 5.2.11 fixes three security issues with severity "high", two security issues with severity "moderate", and one security issue with severity "low" in 5.2.10. + +CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler +================================================================================================= + +The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for +:doc:`authentication via mod_wsgi` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index 7dd10dbb4e..ba39f74082 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -8,6 +8,16 @@ Django 6.0.2 fixes three security issues with severity "high", two security issues with severity "moderate", one security issue with severity "low", and several bugs in 6.0.1. +CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler +================================================================================================= + +The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for +:doc:`authentication via mod_wsgi` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/auth_tests/test_handlers.py b/tests/auth_tests/test_handlers.py index 77f37db009..02743932df 100644 --- a/tests/auth_tests/test_handlers.py +++ b/tests/auth_tests/test_handlers.py @@ -1,4 +1,7 @@ +from unittest import mock + from django.contrib.auth.handlers.modwsgi import check_password, groups_for_user +from django.contrib.auth.hashers import get_hasher from django.contrib.auth.models import Group, User from django.test import TransactionTestCase, override_settings @@ -73,3 +76,28 @@ class ModWsgiHandlerTestCase(TransactionTestCase): self.assertEqual(groups_for_user({}, "test"), [b"test_group"]) self.assertEqual(groups_for_user({}, "test1"), []) + + def test_check_password_fake_runtime(self): + """ + Hasher is run once regardless of whether the user exists. Refs #20760. + """ + User.objects.create_user("test", "test@example.com", "test") + User.objects.create_user("inactive", "test@nono.com", "test", is_active=False) + User.objects.create_user("unusable", "test@nono.com") + + hasher = get_hasher() + + for username, password in [ + ("test", "test"), + ("test", "wrong"), + ("inactive", "test"), + ("inactive", "wrong"), + ("unusable", "test"), + ("doesnotexist", "test"), + ]: + with ( + self.subTest(username=username, password=password), + mock.patch.object(hasher, "encode") as mock_make_password, + ): + check_password({}, username, password) + mock_make_password.assert_called_once()