Hi security team,

Based on your previous preference for a DSA (over PU) for Django,
I have prepared an update for python-django (3:4.2.28-0+deb13u1)
intended for trixie:
  
  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.
  .
      <https://docs.djangoproject.com/en/dev/releases/4.2.28/> (Closes: 
#1126914)


The full diff is attached. Let me know if I should file this as a
PU instead.


Regards,

-- 
      ,''`.
     : :'  :     Chris Lamb
     `. `'`      [email protected] / chris-lamb.co.uk
       `-
diff --git debian/changelog debian/changelog
index bf64b1674..5247a7def 100644
--- debian/changelog
+++ debian/changelog
@@ -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.
+
+    <https://docs.djangoproject.com/en/dev/releases/4.2.28/> (Closes: #1126914)
+
+ -- Chris Lamb <[email protected]>  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 --git Django.egg-info/PKG-INFO Django.egg-info/PKG-INFO
index fe5c1526a..96b3c40fe 100644
--- Django.egg-info/PKG-INFO
+++ Django.egg-info/PKG-INFO
@@ -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 <[email protected]>
 License: BSD-3-Clause
diff --git Django.egg-info/SOURCES.txt Django.egg-info/SOURCES.txt
index c03bfd953..2e56cec11 100644
--- Django.egg-info/SOURCES.txt
+++ Django.egg-info/SOURCES.txt
@@ -4211,6 +4211,7 @@ docs/releases/4.2.24.txt
 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 --git PKG-INFO PKG-INFO
index fe5c1526a..96b3c40fe 100644
--- PKG-INFO
+++ PKG-INFO
@@ -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 <[email protected]>
 License: BSD-3-Clause
diff --git django/__init__.py django/__init__.py
index 06680666d..cc928db2c 100644
--- django/__init__.py
+++ django/__init__.py
@@ -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 --git django/contrib/auth/handlers/modwsgi.py 
django/contrib/auth/handlers/modwsgi.py
index 591ec72cb..086db89fc 100644
--- django/contrib/auth/handlers/modwsgi.py
+++ 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 django/contrib/gis/db/backends/postgis/operations.py 
django/contrib/gis/db/backends/postgis/operations.py
index b68db377f..d18ddab52 100644
--- django/contrib/gis/db/backends/postgis/operations.py
+++ django/contrib/gis/db/backends/postgis/operations.py
@@ -51,6 +51,9 @@ class PostGISOperator(SpatialOperator):
 
         # 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 @@ class PostGISOperator(SpatialOperator):
             )
 
         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 --git django/core/handlers/asgi.py django/core/handlers/asgi.py
index f0125e732..d95182311 100644
--- django/core/handlers/asgi.py
+++ django/core/handlers/asgi.py
@@ -2,6 +2,7 @@ import logging
 import sys
 import tempfile
 import traceback
+from collections import defaultdict
 
 from asgiref.sync import ThreadSensitiveContext, sync_to_async
 
@@ -81,6 +82,7 @@ class ASGIRequest(HttpRequest):
             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 @@ class ASGIRequest(HttpRequest):
             # 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 --git django/db/models/sql/compiler.py django/db/models/sql/compiler.py
index 3b438a1de..279adc704 100644
--- django/db/models/sql/compiler.py
+++ django/db/models/sql/compiler.py
@@ -402,7 +402,7 @@ class SQLCompiler:
                 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 --git django/db/models/sql/query.py django/db/models/sql/query.py
index 3b8071eab..59b40ebfc 100644
--- django/db/models/sql/query.py
+++ django/db/models/sql/query.py
@@ -46,9 +46,11 @@ from django.utils.tree import Node
 
 __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 @@ class Query(BaseExpression):
         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 @@ class Query(BaseExpression):
         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 --git django/utils/text.py django/utils/text.py
index b018f2601..694baf1ac 100644
--- django/utils/text.py
+++ django/utils/text.py
@@ -272,15 +272,11 @@ class Truncator(SimpleLazyObject):
             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 --git docs/releases/4.2.28.txt docs/releases/4.2.28.txt
new file mode 100644
index 000000000..a618474f0
--- /dev/null
+++ docs/releases/4.2.28.txt
@@ -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</howto/deployment/wsgi/apache-auth>`
+allowed remote attackers to enumerate users via a timing attack.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
+
+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 <security-disclosure>`.
+
+CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS
+====================================================================
+
+:ref:`Raster lookups <spatial-lookup-raster>` 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
+<security-disclosure>`.
+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 <security-disclosure>`.
+
+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
+<security-disclosure>`.
+
+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
+<security-disclosure>`.
diff --git docs/releases/index.txt docs/releases/index.txt
index f5a6770bc..e71fc6f52 100644
--- docs/releases/index.txt
+++ docs/releases/index.txt
@@ -26,6 +26,7 @@ versions of the documentation contain the release notes for 
any later releases.
 .. toctree::
    :maxdepth: 1
 
+   4.2.28
    4.2.27
    4.2.26
    4.2.25
diff --git docs/releases/security.txt docs/releases/security.txt
index e5b9878ab..b7bd09f17 100644
--- docs/releases/security.txt
+++ docs/releases/security.txt
@@ -36,6 +36,30 @@ Issues under Django's security process
 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
+<https://www.djangoproject.com/weblog/2025/dec/02/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <56aea00c3c5e1aacf4ed05f8ee06c2e78f02cea0>`
+* Django 5.2 :commit:`(patch) <479415ce5249bcdebeb6570c72df2a87f45a7bbf>`
+* Django 5.1 :commit:`(patch) <9c6a5bde24240382807d13bc3748d08444709355>`
+* Django 4.2 :commit:`(patch) <f997037b235f6b5c9e7c4a501491ec45f3400f3d>`
+
+December 2, 2025 - :cve:`2025-64460`
+------------------------------------
+
+Potential denial-of-service vulnerability in XML serializer text extraction.
+`Full description
+<https://www.djangoproject.com/weblog/2025/dec/02/security-releases/>`__
+
+* 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 --git tests/aggregation/tests.py tests/aggregation/tests.py
index 277c0507f..abd43fc21 100644
--- tests/aggregation/tests.py
+++ tests/aggregation/tests.py
@@ -2,6 +2,7 @@ import datetime
 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 @@ class AggregateTestCase(TestCase):
         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."
-        )
-        with self.assertRaisesMessage(ValueError, msg):
-            Author.objects.aggregate(**{crafted_alias: Avg("age")})
+            "Column aliases cannot contain whitespace characters, hashes, "
+            "control characters, quotation marks, semicolons, or SQL comments."
+        )
+        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 --git tests/annotations/tests.py tests/annotations/tests.py
index d876e3a6f..935691798 100644
--- tests/annotations/tests.py
+++ tests/annotations/tests.py
@@ -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 @@ class NonAggregateAnnotationTestCase(TestCase):
         )
 
     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."
-        )
-        with self.assertRaisesMessage(ValueError, msg):
-            Book.objects.annotate(**{crafted_alias: Value(1)})
+            "Column aliases cannot contain whitespace characters, hashes, "
+            "control characters, quotation marks, semicolons, or SQL comments."
+        )
+        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."
-        )
-        with self.assertRaisesMessage(ValueError, msg):
-            Book.objects.annotate(**{crafted_alias: 
FilteredRelation("author")})
+            "Column aliases cannot contain whitespace characters, hashes, "
+            "control characters, quotation marks, semicolons, or SQL comments."
+        )
+        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 @@ class NonAggregateAnnotationTestCase(TestCase):
             "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 @@ class AliasTests(TestCase):
                     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."
-        )
-        with self.assertRaisesMessage(ValueError, msg):
-            Book.objects.alias(**{crafted_alias: Value(1)})
+            "Column aliases cannot contain whitespace characters, hashes, "
+            "control characters, quotation marks, semicolons, or SQL comments."
+        )
+        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."
-        )
-        with self.assertRaisesMessage(ValueError, msg):
-            Book.objects.alias(**{crafted_alias: FilteredRelation("authors")})
+            "Column aliases cannot contain whitespace characters, hashes, "
+            "control characters, quotation marks, semicolons, or SQL comments."
+        )
+        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 --git tests/asgi/tests.py tests/asgi/tests.py
index f2e293d8b..9395c8626 100644
--- tests/asgi/tests.py
+++ tests/asgi/tests.py
@@ -7,6 +7,7 @@ from asgiref.testing import ApplicationCommunicator
 
 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 @@ class ASGITest(SimpleTestCase):
         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 --git tests/auth_tests/test_handlers.py tests/auth_tests/test_handlers.py
index a6b53a9ef..857c53257 100644
--- tests/auth_tests/test_handlers.py
+++ 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,26 @@ 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", "[email protected]", "test")
+        User.objects.create_user("inactive", "[email protected]", "test", 
is_active=False)
+        User.objects.create_user("unusable", "[email protected]")
+
+        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 --git tests/expressions/test_queryset_values.py 
tests/expressions/test_queryset_values.py
index 080ee0618..afd8a5115 100644
--- tests/expressions/test_queryset_values.py
+++ tests/expressions/test_queryset_values.py
@@ -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 @@ class ValuesExpressionsTests(TestCase):
         )
 
     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 --git tests/filtered_relation/tests.py tests/filtered_relation/tests.py
index 0fce8b092..4847d1f04 100644
--- tests/filtered_relation/tests.py
+++ tests/filtered_relation/tests.py
@@ -211,6 +211,19 @@ class FilteredRelationTests(TestCase):
             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 --git tests/gis_tests/rasterapp/test_rasterfield.py 
tests/gis_tests/rasterapp/test_rasterfield.py
index 3f2ce770a..89c4ec485 100644
--- tests/gis_tests/rasterapp/test_rasterfield.py
+++ tests/gis_tests/rasterapp/test_rasterfield.py
@@ -2,7 +2,11 @@ import json
 
 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 @@ class RasterFieldTest(TransactionTestCase):
         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 --git tests/ordering/tests.py tests/ordering/tests.py
index b29404ed7..30da02587 100644
--- tests/ordering/tests.py
+++ tests/ordering/tests.py
@@ -7,6 +7,7 @@ from django.db.models import (
     Count,
     DateTimeField,
     F,
+    FilteredRelation,
     Max,
     OrderBy,
     OuterRef,
@@ -392,6 +393,35 @@ class OrderingTests(TestCase):
             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 --git tests/queries/tests.py tests/queries/tests.py
index 2290ea29b..d0af410eb 100644
--- tests/queries/tests.py
+++ tests/queries/tests.py
@@ -2,6 +2,7 @@ import datetime
 import pickle
 import sys
 import unittest
+from itertools import chain
 from operator import attrgetter
 from threading import Lock
 
@@ -1941,13 +1942,18 @@ class Queries5Tests(TestCase):
         )
 
     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."
-        )
-        with self.assertRaisesMessage(ValueError, msg):
-            Note.objects.extra(select={crafted_alias: "1"})
+            "Column aliases cannot contain whitespace characters, hashes, "
+            "control characters, quotation marks, semicolons, or SQL comments."
+        )
+        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 --git tests/runtests.py tests/runtests.py
index b67898839..b3150ab69 100755
--- tests/runtests.py
+++ tests/runtests.py
@@ -65,16 +65,6 @@ RUNTESTS_DIR = os.path.abspath(os.path.dirname(__file__))
 
 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 get_filtered_test_modules(start_at, start_after, 
gis_enabled, test_labels=No
 
 
 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 setup_run_tests(verbosity, start_at, start_after, 
test_labels=None):
 
 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 @@ def paired_tests(paired_test, options, test_labels, 
start_at, start_after):
 
 
 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 --git tests/utils_tests/test_html.py tests/utils_tests/test_html.py
index f755b8ceb..a5acc582f 100644
--- tests/utils_tests/test_html.py
+++ tests/utils_tests/test_html.py
@@ -1,3 +1,4 @@
+import math
 import os
 import sys
 from datetime import datetime
@@ -92,17 +93,35 @@ class TestUtilsHtml(SimpleTestCase):
         # 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 @@ class TestUtilsHtml(SimpleTestCase):
             # https://bugs.python.org/issue20288
             ("&gotcha&#;<>", "&gotcha&#;<>"),
             ("<sc<!-- -->ript>test<<!-- -->/script>", "ript>test"),
-            ("<script>alert()</script>&h", "alert()h"),
+            (
+                "<script>alert()</script>&h",
+                "alert()&h;" if htmlparser_fixed_incomplete_entities else 
"alert()h",
+            ),
             (
                 "><!" + ("&" * 16000) + "D",
-                ">" if htmlparser_fixed else "><!" + ("&" * 16000) + "D",
+                ">" if htmlparser_fixed_security else "><!" + ("&" * 16000) + 
"D",
             ),
             ("X<<<<br>br>br>br>X", "XX"),
             ("<" * 50 + "a>" * 50, ""),
             (
                 ">" + "<a" * 500 + "a",
-                ">" if htmlparser_fixed else ">" + "<a" * 500 + "a",
+                ">" if htmlparser_fixed_security else ">" + "<a" * 500 + "a",
             ),
             ("<a" * 49 + "a" * 951, "<a" * 49 + "a" * 951),
             ("<" + "a" * 1_002, "<" + "a" * 1_002),
diff --git tests/utils_tests/test_text.py tests/utils_tests/test_text.py
index d1890e7b6..5a018d732 100644
--- tests/utils_tests/test_text.py
+++ tests/utils_tests/test_text.py
@@ -95,6 +95,16 @@ class TestUtilsText(SimpleTestCase):
             text.Truncator(lazystr("The quick brown fox")).chars(10), "The 
quick\u2026"
         )
 
+    def test_truncate_chars_html_with_misnested_tags(self):
+        # LIFO removal keeps all tags when a middle tag is closed out of order.
+        # With <a><b><c></b>, the </b> doesn't match <c>, so all tags remain
+        # in the stack and are properly closed at truncation.
+        truncator = text.Truncator("<a><b><c></b>XXXX")
+        self.assertEqual(
+            truncator.chars(2, html=True, truncate=""),
+            "<a><b><c></b>XX</c></b></a>",
+        )
+
     @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

Reply via email to