Version in base suite: 4.2.23-1 Version in overlay suite: 4.2.27-0+deb13u1 Base version: python-django_4.2.27-0+deb13u1 Target version: python-django_4.2.28-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-django/python-django_4.2.27-0+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-django/python-django_4.2.28-0+deb13u1.dsc Django.egg-info/PKG-INFO | 2 Django.egg-info/SOURCES.txt | 1 PKG-INFO | 2 debian/changelog | 38 ++++++++ django/__init__.py | 2 django/contrib/auth/handlers/modwsgi.py | 37 ++++++-- django/contrib/gis/db/backends/postgis/operations.py | 6 + django/core/handlers/asgi.py | 7 - django/db/models/sql/compiler.py | 2 django/db/models/sql/query.py | 15 ++- django/utils/text.py | 14 +-- docs/releases/4.2.28.txt | 82 +++++++++++++++++++ docs/releases/index.txt | 1 docs/releases/security.txt | 24 +++++ tests/aggregation/tests.py | 16 ++- tests/annotations/tests.py | 66 ++++++++++----- tests/asgi/tests.py | 27 ++++++ tests/auth_tests/test_handlers.py | 26 ++++++ tests/expressions/test_queryset_values.py | 36 +++++--- tests/filtered_relation/tests.py | 13 +++ tests/gis_tests/rasterapp/test_rasterfield.py | 47 ++++++++++ tests/ordering/tests.py | 30 ++++++ tests/queries/tests.py | 16 ++- tests/runtests.py | 26 ++---- tests/utils_tests/test_html.py | 36 ++++++-- tests/utils_tests/test_text.py | 10 ++ 26 files changed, 486 insertions(+), 96 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp8tj80frp/python-django_4.2.27-0+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp8tj80frp/python-django_4.2.28-0+deb13u1.dsc: no acceptable signature found diff -Nru python-django-4.2.27/Django.egg-info/PKG-INFO python-django-4.2.28/Django.egg-info/PKG-INFO --- python-django-4.2.27/Django.egg-info/PKG-INFO 2025-12-02 12:46:18.000000000 +0000 +++ python-django-4.2.28/Django.egg-info/PKG-INFO 2026-02-03 13:31:34.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: Django -Version: 4.2.27 +Version: 4.2.28 Summary: A high-level Python web framework that encourages rapid development and clean, pragmatic design. Author-email: Django Software Foundation License: BSD-3-Clause diff -Nru python-django-4.2.27/Django.egg-info/SOURCES.txt python-django-4.2.28/Django.egg-info/SOURCES.txt --- python-django-4.2.27/Django.egg-info/SOURCES.txt 2025-12-02 12:46:18.000000000 +0000 +++ python-django-4.2.28/Django.egg-info/SOURCES.txt 2026-02-03 13:31:34.000000000 +0000 @@ -4211,6 +4211,7 @@ docs/releases/4.2.25.txt docs/releases/4.2.26.txt docs/releases/4.2.27.txt +docs/releases/4.2.28.txt docs/releases/4.2.3.txt docs/releases/4.2.4.txt docs/releases/4.2.5.txt diff -Nru python-django-4.2.27/PKG-INFO python-django-4.2.28/PKG-INFO --- python-django-4.2.27/PKG-INFO 2025-12-02 12:46:19.003385000 +0000 +++ python-django-4.2.28/PKG-INFO 2026-02-03 13:31:37.694834700 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: Django -Version: 4.2.27 +Version: 4.2.28 Summary: A high-level Python web framework that encourages rapid development and clean, pragmatic design. Author-email: Django Software Foundation License: BSD-3-Clause diff -Nru python-django-4.2.27/debian/changelog python-django-4.2.28/debian/changelog --- python-django-4.2.27/debian/changelog 2026-01-23 18:43:29.000000000 +0000 +++ python-django-4.2.28/debian/changelog 2026-02-18 22:44:14.000000000 +0000 @@ -1,3 +1,41 @@ +python-django (3:4.2.28-0+deb13u1) trixie-security; urgency=high + + * New upstream security release: + + - CVE-2025-13473: The check_password function in + django.contrib.auth.handlers.modwsgi for authentication via mod_wsgi + allowed remote attackers to enumerate users via a timing attack. + + - CVE-2025-14550: When receiving duplicates of a single header, ASGIRequest + allowed a remote attacker to cause a potential denial-of-service via a + specifically created request with multiple duplicate headers. The + vulnerability resulted from repeated string concatenation while combining + repeated headers, which produced super-linear computation resulting in + service degradation or outage. + + - CVE-2026-1207: Raster lookups on RasterField (only implemented on + PostGIS) allowed remote attackers to inject SQL via the band index + parameter. + + - CVE-2026-1285: The django.utils.text.Truncator.chars() and + Truncator.words() methods (with html=True) and the truncatechars_html and + truncatewords_html template filters allowed a remote attacker to cause a + potential denial-of-service via crafted inputs containing a large number + of unmatched HTML end tags. + + - CVE-2026-1287: FilteredRelation was subject to SQL injection in column + aliases via control characters using a suitably crafted dictionary, with + dictionary expansion, as the **kwargs passed to QuerySet methods + annotate(), aggregate(), extra(), values(), values_list() and alias(). + + - CVE-2026-1312: QuerySet.order_by() was subject to SQL injection in column + aliases containing periods when the same alias is, using a suitably + crafted dictionary, with dictionary expansion, used in FilteredRelation. + + (Closes: #1126914) + + -- Chris Lamb Wed, 18 Feb 2026 14:44:14 -0800 + python-django (3:4.2.27-0+deb13u1) trixie-security; urgency=high * New upstream security release: diff -Nru python-django-4.2.27/django/__init__.py python-django-4.2.28/django/__init__.py --- python-django-4.2.27/django/__init__.py 2025-12-02 12:45:49.000000000 +0000 +++ python-django-4.2.28/django/__init__.py 2026-02-03 13:31:14.000000000 +0000 @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (4, 2, 27, "final", 0) +VERSION = (4, 2, 28, "final", 0) __version__ = get_version(VERSION) diff -Nru python-django-4.2.27/django/contrib/auth/handlers/modwsgi.py python-django-4.2.28/django/contrib/auth/handlers/modwsgi.py --- python-django-4.2.27/django/contrib/auth/handlers/modwsgi.py 2024-10-16 13:20:16.000000000 +0000 +++ python-django-4.2.28/django/contrib/auth/handlers/modwsgi.py 2026-02-03 13:24:11.000000000 +0000 @@ -4,24 +4,47 @@ 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 -Nru python-django-4.2.27/django/contrib/gis/db/backends/postgis/operations.py python-django-4.2.28/django/contrib/gis/db/backends/postgis/operations.py --- python-django-4.2.27/django/contrib/gis/db/backends/postgis/operations.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/django/contrib/gis/db/backends/postgis/operations.py 2026-02-03 13:30:55.000000000 +0000 @@ -51,6 +51,9 @@ # Look for band indices and inject them if provided. if lookup.band_lhs is not None and lhs_is_raster: + if not isinstance(lookup.band_lhs, int): + name = lookup.band_lhs.__class__.__name__ + raise TypeError(f"Band index must be an integer, but got {name!r}.") if not self.func: raise ValueError( "Band indices are not allowed for this operator, it works on bbox " @@ -62,6 +65,9 @@ ) if lookup.band_rhs is not None and rhs_is_raster: + if not isinstance(lookup.band_rhs, int): + name = lookup.band_rhs.__class__.__name__ + raise TypeError(f"Band index must be an integer, but got {name!r}.") if not self.func: raise ValueError( "Band indices are not allowed for this operator, it works on bbox " diff -Nru python-django-4.2.27/django/core/handlers/asgi.py python-django-4.2.28/django/core/handlers/asgi.py --- python-django-4.2.27/django/core/handlers/asgi.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/django/core/handlers/asgi.py 2026-02-03 13:30:55.000000000 +0000 @@ -2,6 +2,7 @@ import sys import tempfile import traceback +from collections import defaultdict from asgiref.sync import ThreadSensitiveContext, sync_to_async @@ -81,6 +82,7 @@ self.META["SERVER_NAME"] = "unknown" self.META["SERVER_PORT"] = "0" # Headers go into META. + _headers = defaultdict(list) for name, value in self.scope.get("headers", []): name = name.decode("latin1") if name == "content-length": @@ -92,9 +94,8 @@ # HTTP/2 say only ASCII chars are allowed in headers, but decode # latin1 just in case. value = value.decode("latin1") - if corrected_name in self.META: - value = self.META[corrected_name] + "," + value - self.META[corrected_name] = value + _headers[corrected_name].append(value) + self.META.update({name: ",".join(value) for name, value in _headers.items()}) # Pull out request encoding, if provided. self._set_content_type_params(self.META) # Directly assign the body file to be our stream. diff -Nru python-django-4.2.27/django/db/models/sql/compiler.py python-django-4.2.28/django/db/models/sql/compiler.py --- python-django-4.2.27/django/db/models/sql/compiler.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/django/db/models/sql/compiler.py 2026-02-03 13:30:55.000000000 +0000 @@ -402,7 +402,7 @@ yield OrderBy(expr, descending=descending), False continue - if "." in field: + if "." in field and field in self.query.extra_order_by: # This came in through an extra(order_by=...) addition. Pass it # on verbatim. table, col = col.split(".", 1) diff -Nru python-django-4.2.27/django/db/models/sql/query.py python-django-4.2.28/django/db/models/sql/query.py --- python-django-4.2.27/django/db/models/sql/query.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/django/db/models/sql/query.py 2026-02-03 13:30:55.000000000 +0000 @@ -46,9 +46,11 @@ __all__ = ["Query", "RawQuery"] -# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline -# SQL comments are forbidden in column aliases. -FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/") +# Quotation marks ('"`[]), whitespace characters, control characters, +# semicolons, hashes, or inline SQL comments are forbidden in column aliases. +FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile( + r"['`\"\]\[;\s\x00-\x1F\x7F-\x9F]|#|--|/\*|\*/" +) # Inspired from # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS @@ -1124,7 +1126,7 @@ if FORBIDDEN_ALIAS_PATTERN.search(alias): raise ValueError( "Column aliases cannot contain whitespace characters, hashes, " - "quotation marks, semicolons, or SQL comments." + "control characters, quotation marks, semicolons, or SQL comments." ) def add_annotation(self, annotation, alias, select=True): @@ -1620,6 +1622,11 @@ return target_clause def add_filtered_relation(self, filtered_relation, alias): + if "." in alias: + raise ValueError( + "FilteredRelation doesn't support aliases with periods " + "(got %r)." % alias + ) self.check_alias(alias) filtered_relation.alias = alias lookups = dict(get_children_from_q(filtered_relation.condition)) diff -Nru python-django-4.2.27/django/utils/text.py python-django-4.2.28/django/utils/text.py --- python-django-4.2.27/django/utils/text.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/django/utils/text.py 2026-02-03 13:30:56.000000000 +0000 @@ -272,15 +272,11 @@ if self_closing or tagname in html4_singlets: pass elif closing_tag: - # Check for match in open tags list - try: - i = open_tags.index(tagname) - except ValueError: - pass - else: - # SGML: An end tag closes, back to the matching start tag, - # all unclosed intervening start tags with omitted end tags - open_tags = open_tags[i + 1 :] + # Remove from the list only if the tag matches the most + # recently opened tag (LIFO). This avoids O(n) linear scans + # for unmatched end tags if `list.index()` would be called. + if open_tags and open_tags[0] == tagname: + open_tags = open_tags[1:] else: # Add it to the start of the open tags list open_tags.insert(0, tagname) diff -Nru python-django-4.2.27/docs/releases/4.2.28.txt python-django-4.2.28/docs/releases/4.2.28.txt --- python-django-4.2.27/docs/releases/4.2.28.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-4.2.28/docs/releases/4.2.28.txt 2026-02-03 13:30:56.000000000 +0000 @@ -0,0 +1,82 @@ +=========================== +Django 4.2.28 release notes +=========================== + +*February 3, 2026* + +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 +`. + +CVE-2025-14550: Potential denial-of-service vulnerability via repeated headers when using ASGI +============================================================================================== + +When receiving duplicates of a single header, ``ASGIRequest`` allowed a remote +attacker to cause a potential denial-of-service via a specifically created +request with multiple duplicate headers. The vulnerability resulted from +repeated string concatenation while combining repeated headers, which +produced super-linear computation resulting in service degradation or outage. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS +==================================================================== + +:ref:`Raster lookups ` on GIS fields (only implemented +on PostGIS) were subject to SQL injection if untrusted data was used as a band +index. + +As a reminder, all untrusted user input should be validated before use. + +This issue has severity "high" according to the :ref:`Django security policy +`. +Django 4.2.28 fixes two security issues with severity "moderate", three +security issues with severity "moderate", and one security issue with severity +"low" in 4.2.27. + +CVE-2026-1285: Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` HTML methods +======================================================================================================== + +``django.utils.text.Truncator.chars()`` and ``Truncator.words()`` methods (with +``html=True``) and the :tfilter:`truncatechars_html` and +:tfilter:`truncatewords_html` template filters were subject to a potential +denial-of-service attack via certain inputs with a large number of unmatched +HTML end tags, which could cause quadratic time complexity during HTML parsing. + +This issue has severity "moderate" according to the Django security policy. +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-1287: Potential SQL injection in column aliases via control characters +=============================================================================== + +:class:`.FilteredRelation` was subject to SQL injection in column aliases via +control characters, using a suitably crafted dictionary, with dictionary +expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, +:meth:`~.QuerySet.aggregate`, :meth:`~.QuerySet.extra`, +:meth:`~.QuerySet.values`, :meth:`~.QuerySet.values_list`, and +:meth:`~.QuerySet.alias`. + +This issue has severity "high" according to the :ref:`Django security policy +`. + +CVE-2026-1312: Potential SQL injection via ``QuerySet.order_by`` and ``FilteredRelation`` +========================================================================================= + +:meth:`.QuerySet.order_by` was subject to SQL injection in column aliases +containing periods when the same alias was, using a suitably crafted +dictionary, with dictionary expansion, used in :class:`.FilteredRelation`. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff -Nru python-django-4.2.27/docs/releases/index.txt python-django-4.2.28/docs/releases/index.txt --- python-django-4.2.27/docs/releases/index.txt 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/docs/releases/index.txt 2026-02-03 13:30:56.000000000 +0000 @@ -26,6 +26,7 @@ .. toctree:: :maxdepth: 1 + 4.2.28 4.2.27 4.2.26 4.2.25 diff -Nru python-django-4.2.27/docs/releases/security.txt python-django-4.2.28/docs/releases/security.txt --- python-django-4.2.27/docs/releases/security.txt 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/docs/releases/security.txt 2026-02-03 13:30:56.000000000 +0000 @@ -36,6 +36,30 @@ All security issues have been handled under versions of Django's security process. These are listed below. +December 2, 2025 - :cve:`2025-13372` +------------------------------------ + +Potential SQL injection in ``FilteredRelation`` column aliases on PostgreSQL. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <56aea00c3c5e1aacf4ed05f8ee06c2e78f02cea0>` +* Django 5.2 :commit:`(patch) <479415ce5249bcdebeb6570c72df2a87f45a7bbf>` +* Django 5.1 :commit:`(patch) <9c6a5bde24240382807d13bc3748d08444709355>` +* Django 4.2 :commit:`(patch) ` + +December 2, 2025 - :cve:`2025-64460` +------------------------------------ + +Potential denial-of-service vulnerability in XML serializer text extraction. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <1dbd07a608e495a0c229edaaf84d58d8976313b5>` +* Django 5.2 :commit:`(patch) <99e7d22f55497278d0bcb2e15e72ef532e62a31d>` +* Django 5.1 :commit:`(patch) <0db9ea4669312f1f4973e09f4bca06ab9c1ec74b>` +* Django 4.2 :commit:`(patch) <4d2b8803bebcdefd2b76e9e8fc528d5fddea93f0>` + November 5, 2025 - :cve:`2025-64458` ------------------------------------ diff -Nru python-django-4.2.27/tests/aggregation/tests.py python-django-4.2.28/tests/aggregation/tests.py --- python-django-4.2.27/tests/aggregation/tests.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/tests/aggregation/tests.py 2026-02-03 13:30:56.000000000 +0000 @@ -2,6 +2,7 @@ import math import re from decimal import Decimal +from itertools import chain from django.core.exceptions import FieldError from django.db import connection @@ -2088,13 +2089,18 @@ self.assertEqual(len(qs), 6) def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "aggregation_author"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Author.objects.aggregate(**{crafted_alias: Avg("age")}) + for crafted_alias in [ + """injected_name" from "aggregation_author"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Author.objects.aggregate(**{crafted_alias: Avg("age")}) def test_exists_extra_where_with_aggregate(self): qs = Book.objects.annotate( diff -Nru python-django-4.2.27/tests/annotations/tests.py python-django-4.2.28/tests/annotations/tests.py --- python-django-4.2.27/tests/annotations/tests.py 2025-12-02 12:44:19.000000000 +0000 +++ python-django-4.2.28/tests/annotations/tests.py 2026-02-03 13:30:56.000000000 +0000 @@ -1,5 +1,6 @@ import datetime from decimal import Decimal +from itertools import chain from django.core.exceptions import FieldDoesNotExist, FieldError from django.db import connection @@ -1115,22 +1116,32 @@ ) def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.annotate(**{crafted_alias: Value(1)}) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.annotate(**{crafted_alias: Value(1)}) def test_alias_filtered_relation_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) def test_alias_forbidden_chars(self): tests = [ @@ -1148,10 +1159,11 @@ "alias[", "alias]", "ali#as", + "ali\0as", ] msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) for crafted_alias in tests: with self.subTest(crafted_alias): @@ -1428,22 +1440,32 @@ getattr(qs, operation)("rating_alias") def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.alias(**{crafted_alias: Value(1)}) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.alias(**{crafted_alias: Value(1)}) def test_alias_filtered_relation_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) def test_alias_filtered_relation_sql_injection_dollar_sign(self): qs = Book.objects.alias( diff -Nru python-django-4.2.27/tests/asgi/tests.py python-django-4.2.28/tests/asgi/tests.py --- python-django-4.2.27/tests/asgi/tests.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/tests/asgi/tests.py 2026-02-03 13:30:56.000000000 +0000 @@ -7,6 +7,7 @@ from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler from django.core.asgi import get_asgi_application +from django.core.handlers.asgi import ASGIRequest from django.core.signals import request_finished, request_started from django.db import close_old_connections from django.test import ( @@ -193,6 +194,32 @@ self.assertEqual(response_body["type"], "http.response.body") self.assertEqual(response_body["body"], b"Echo!") + async def test_meta_not_modified_with_repeat_headers(self): + scope = self.async_request_factory._base_scope(path="/", http_version="2.0") + scope["headers"] = [(b"foo", b"bar")] * 200_000 + + setitem_count = 0 + + class InstrumentedDict(dict): + def __setitem__(self, *args, **kwargs): + nonlocal setitem_count + setitem_count += 1 + super().__setitem__(*args, **kwargs) + + class InstrumentedASGIRequest(ASGIRequest): + @property + def META(self): + return self._meta + + @META.setter + def META(self, value): + self._meta = InstrumentedDict(**value) + + request = InstrumentedASGIRequest(scope, None) + + self.assertEqual(len(request.headers["foo"].split(",")), 200_000) + self.assertLessEqual(setitem_count, 100) + async def test_untouched_request_body_gets_closed(self): application = get_asgi_application() scope = self.async_request_factory._base_scope(method="POST", path="/post/") diff -Nru python-django-4.2.27/tests/auth_tests/test_handlers.py python-django-4.2.28/tests/auth_tests/test_handlers.py --- python-django-4.2.27/tests/auth_tests/test_handlers.py 2025-12-02 12:30:32.000000000 +0000 +++ python-django-4.2.28/tests/auth_tests/test_handlers.py 2026-02-03 13:30:56.000000000 +0000 @@ -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,26 @@ 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): + with mock.patch.object(hasher, "encode") as mock_make_password: + check_password({}, username, password) + mock_make_password.assert_called_once() diff -Nru python-django-4.2.27/tests/expressions/test_queryset_values.py python-django-4.2.28/tests/expressions/test_queryset_values.py --- python-django-4.2.27/tests/expressions/test_queryset_values.py 2025-12-02 12:30:32.000000000 +0000 +++ python-django-4.2.28/tests/expressions/test_queryset_values.py 2026-02-03 13:25:50.000000000 +0000 @@ -1,3 +1,5 @@ +from itertools import chain + from django.db.models import F, Sum from django.test import TestCase, skipUnlessDBFeature @@ -35,26 +37,36 @@ ) def test_values_expression_alias_sql_injection(self): - crafted_alias = """injected_name" from "expressions_company"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Company.objects.values(**{crafted_alias: F("ceo__salary")}) + for crafted_alias in [ + """injected_name" from "expressions_company"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Company.objects.values(**{crafted_alias: F("ceo__salary")}) @skipUnlessDBFeature("supports_json_field") def test_values_expression_alias_sql_injection_json_field(self): - crafted_alias = """injected_name" from "expressions_company"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - JSONFieldModel.objects.values(f"data__{crafted_alias}") + for crafted_alias in [ + """injected_name" from "expressions_company"; --""", + # Control characters. + *(chr(c) for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + JSONFieldModel.objects.values(f"data__{crafted_alias}") - with self.assertRaisesMessage(ValueError, msg): - JSONFieldModel.objects.values_list(f"data__{crafted_alias}") + with self.assertRaisesMessage(ValueError, msg): + JSONFieldModel.objects.values_list(f"data__{crafted_alias}") def test_values_expression_group_by(self): # values() applies annotate() first, so values selected are grouped by diff -Nru python-django-4.2.27/tests/filtered_relation/tests.py python-django-4.2.28/tests/filtered_relation/tests.py --- python-django-4.2.27/tests/filtered_relation/tests.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/tests/filtered_relation/tests.py 2026-02-03 13:30:56.000000000 +0000 @@ -211,6 +211,19 @@ str(queryset.query), ) + def test_period_forbidden(self): + msg = ( + "FilteredRelation doesn't support aliases with periods (got 'book.alice')." + ) + with self.assertRaisesMessage(ValueError, msg): + Author.objects.annotate( + **{ + "book.alice": FilteredRelation( + "book", condition=Q(book__title__iexact="poem by alice") + ) + } + ) + def test_multiple(self): qs = ( Author.objects.annotate( diff -Nru python-django-4.2.27/tests/gis_tests/rasterapp/test_rasterfield.py python-django-4.2.28/tests/gis_tests/rasterapp/test_rasterfield.py --- python-django-4.2.27/tests/gis_tests/rasterapp/test_rasterfield.py 2024-10-16 13:20:16.000000000 +0000 +++ python-django-4.2.28/tests/gis_tests/rasterapp/test_rasterfield.py 2026-02-03 13:25:06.000000000 +0000 @@ -2,7 +2,11 @@ from django.contrib.gis.db.models.fields import BaseSpatialField from django.contrib.gis.db.models.functions import Distance -from django.contrib.gis.db.models.lookups import DistanceLookupBase, GISLookup +from django.contrib.gis.db.models.lookups import ( + DistanceLookupBase, + GISLookup, + RasterBandTransform, +) from django.contrib.gis.gdal import GDALRaster from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.measure import D @@ -356,6 +360,47 @@ with self.assertRaisesMessage(ValueError, msg): qs.count() + def test_lookup_invalid_band_rhs(self): + rast = GDALRaster(json.loads(JSON_RASTER)) + qs = RasterModel.objects.filter(rast__contains=(rast, "evil")) + msg = "Band index must be an integer, but got 'str'." + with self.assertRaisesMessage(TypeError, msg): + qs.count() + + def test_lookup_invalid_band_lhs(self): + """ + Typical left-hand side usage is protected against non-integers, but for + defense-in-depth purposes, construct custom lookups that evade the + `int()` and `+ 1` checks in the lookups shipped by django.contrib.gis. + """ + + # Evade the int() call in RasterField.get_transform(). + class MyRasterBandTransform(RasterBandTransform): + band_index = "evil" + + def process_band_indices(self, *args, **kwargs): + self.band_lhs = self.lhs.band_index + self.band_rhs, *self.rhs_params = self.rhs_params + + # Evade the `+ 1` call in BaseSpatialField.process_band_indices(). + ContainsLookup = RasterModel._meta.get_field("rast").get_lookup("contains") + + class MyContainsLookup(ContainsLookup): + def process_band_indices(self, *args, **kwargs): + self.band_lhs = self.lhs.band_index + self.band_rhs, *self.rhs_params = self.rhs_params + + RasterField = RasterModel._meta.get_field("rast") + RasterField.register_lookup(MyContainsLookup, "contains") + self.addCleanup(RasterField.register_lookup, ContainsLookup, "contains") + + qs = RasterModel.objects.annotate( + transformed=MyRasterBandTransform("rast") + ).filter(transformed__contains=(F("transformed"), 1)) + msg = "Band index must be an integer, but got 'str'." + with self.assertRaisesMessage(TypeError, msg): + list(qs) + def test_isvalid_lookup_with_raster_error(self): qs = RasterModel.objects.filter(rast__isvalid=True) msg = ( diff -Nru python-django-4.2.27/tests/ordering/tests.py python-django-4.2.28/tests/ordering/tests.py --- python-django-4.2.27/tests/ordering/tests.py 2025-12-02 12:30:32.000000000 +0000 +++ python-django-4.2.28/tests/ordering/tests.py 2026-02-03 13:26:42.000000000 +0000 @@ -7,6 +7,7 @@ Count, DateTimeField, F, + FilteredRelation, Max, OrderBy, OuterRef, @@ -392,6 +393,35 @@ attrgetter("headline"), ) + def test_alias_with_period_shadows_table_name(self): + """ + Aliases with periods are not confused for table names from extra(). + """ + Article.objects.update(author=self.author_2) + Article.objects.create( + headline="Backdated", pub_date=datetime(1900, 1, 1), author=self.author_1 + ) + crafted = "ordering_article.pub_date" + + qs = Article.objects.annotate(**{crafted: F("author")}).order_by("-" + crafted) + self.assertNotEqual(qs[0].headline, "Backdated") + + relation = FilteredRelation("author") + msg = ( + "FilteredRelation doesn't support aliases with periods " + "(got 'ordering_article.pub_date')." + ) + with self.assertRaisesMessage(ValueError, msg): + qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted) + # Before, unlike F(), which causes ordering expressions to be + # replaced by ordinals like n in ORDER BY n, these were ordered by + # pub_date instead of author. + # The Article model orders by -pk, so sorting on author will place + # first any article by author2 instead of the backdated one. + # This assertion is reachable if FilteredRelation.__init__() starts + # supporting periods in aliases in the future. + self.assertNotEqual(qs2[0].headline, "Backdated") + def test_order_by_pk(self): """ 'pk' works as an ordering option in Meta. diff -Nru python-django-4.2.27/tests/queries/tests.py python-django-4.2.28/tests/queries/tests.py --- python-django-4.2.27/tests/queries/tests.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/tests/queries/tests.py 2026-02-03 13:30:56.000000000 +0000 @@ -2,6 +2,7 @@ import pickle import sys import unittest +from itertools import chain from operator import attrgetter from threading import Lock @@ -1941,13 +1942,18 @@ ) def test_extra_select_alias_sql_injection(self): - crafted_alias = """injected_name" from "queries_note"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Note.objects.extra(select={crafted_alias: "1"}) + for crafted_alias in [ + """injected_name" from "queries_note"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Note.objects.extra(select={crafted_alias: "1"}) def test_queryset_reuse(self): # Using querysets doesn't mutate aliases. diff -Nru python-django-4.2.27/tests/runtests.py python-django-4.2.28/tests/runtests.py --- python-django-4.2.27/tests/runtests.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/tests/runtests.py 2026-02-03 13:30:56.000000000 +0000 @@ -65,16 +65,6 @@ TEMPLATE_DIR = os.path.join(RUNTESTS_DIR, "templates") -# Create a specific subdirectory for the duration of the test suite. -TMPDIR = tempfile.mkdtemp(prefix="django_") -# Set the TMPDIR environment variable in addition to tempfile.tempdir -# so that children processes inherit it. -tempfile.tempdir = os.environ["TMPDIR"] = TMPDIR - -# Removing the temporary TMPDIR. -atexit.register(shutil.rmtree, TMPDIR) - - # This is a dict mapping RUNTESTS_DIR subdirectory to subdirectories of that # directory to skip when searching for test modules. SUBDIRS_TO_SKIP = { @@ -197,6 +187,7 @@ def setup_collect_tests(start_at, start_after, test_labels=None): + TMPDIR = os.environ["TMPDIR"] state = { "INSTALLED_APPS": settings.INSTALLED_APPS, "ROOT_URLCONF": getattr(settings, "ROOT_URLCONF", ""), @@ -334,13 +325,6 @@ def teardown_run_tests(state): teardown_collect_tests(state) - # Discard the multiprocessing.util finalizer that tries to remove a - # temporary directory that's already removed by this script's - # atexit.register(shutil.rmtree, TMPDIR) handler. Prevents - # FileNotFoundError at the end of a test run (#27890). - from multiprocessing.util import _finalizer_registry - - _finalizer_registry.pop((-100, 0), None) del os.environ["RUNNING_DJANGOS_TEST_SUITE"] @@ -539,6 +523,14 @@ if __name__ == "__main__": + # Create a specific subdirectory for the duration of the test suite. + TMPDIR = tempfile.mkdtemp(prefix="django_") + # Set the TMPDIR environment variable in addition to tempfile.tempdir + # so that children processes inherit it. + tempfile.tempdir = os.environ["TMPDIR"] = TMPDIR + # Remove the temporary TMPDIR. + atexit.register(shutil.rmtree, TMPDIR) + parser = argparse.ArgumentParser(description="Run the Django test suite.") parser.add_argument( "modules", diff -Nru python-django-4.2.27/tests/utils_tests/test_html.py python-django-4.2.28/tests/utils_tests/test_html.py --- python-django-4.2.27/tests/utils_tests/test_html.py 2025-12-02 12:43:50.000000000 +0000 +++ python-django-4.2.28/tests/utils_tests/test_html.py 2026-02-03 13:30:56.000000000 +0000 @@ -1,3 +1,4 @@ +import math import os import sys from datetime import datetime @@ -92,17 +93,35 @@ # old and new results. The check below is temporary until all supported # Python versions and CI workers include the fix. See: # https://github.com/python/cpython/commit/6eb6c5db - min_fixed = { + min_fixed_security = { (3, 14): (3, 14), (3, 13): (3, 13, 6), (3, 12): (3, 12, 12), (3, 11): (3, 11, 14), (3, 10): (3, 10, 19), (3, 9): (3, 9, 24), + # Not fixed in 3.8. + (3, 8): (3, 8, math.inf), } - py_version = sys.version_info[:2] - htmlparser_fixed = ( - py_version in min_fixed and sys.version_info >= min_fixed[py_version] + # Similarly, there was a fix for terminating incomplete entities. See: + # https://github.com/python/cpython/commit/95296a9d + min_fixed_incomplete_entities = { + (3, 14): (3, 14, 1), + (3, 13): (3, 13, 10), + # Not fixed in the following versions. + (3, 12): (3, 12, math.inf), + (3, 11): (3, 11, math.inf), + (3, 10): (3, 10, math.inf), + (3, 9): (3, 9, math.inf), + (3, 8): (3, 8, math.inf), + } + major_version = sys.version_info[:2] + htmlparser_fixed_security = sys.version_info >= min_fixed_security.get( + major_version, major_version + ) + htmlparser_fixed_incomplete_entities = ( + sys.version_info + >= min_fixed_incomplete_entities.get(major_version, major_version) ) items = ( ( @@ -130,16 +149,19 @@ # https://bugs.python.org/issue20288 ("&gotcha&#;<>", "&gotcha&#;<>"), ("ript>test</script>", "ript>test"), - ("&h", "alert()h"), + ( + "&h", + "alert()&h;" if htmlparser_fixed_incomplete_entities else "alert()h", + ), ( ">" if htmlparser_fixed else ">" if htmlparser_fixed_security else ">br>br>br>X", "XX"), ("<" * 50 + "a>" * 50, ""), ( ">" + "" if htmlparser_fixed else ">" + "" if htmlparser_fixed_security else ">" + ", the doesn't match , so all tags remain + # in the stack and are properly closed at truncation. + truncator = text.Truncator("XXXX") + self.assertEqual( + truncator.chars(2, html=True, truncate=""), + "XX", + ) + @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000) def test_truncate_chars_html_size_limit(self): max_len = text.Truncator.MAX_LENGTH_HTML