Moritz Mühlenhoff wrote:

> Most of these also appear to affect 3.2 boomworm is support for a few 
> more months
> before it becomes LTS; could you please also prepare a debdiff for 
> bookworm-security?

Sure; please see attached.


Best wishes,

-- 
      ,''`.
     : :'  :     Chris Lamb
     `. `'`      [email protected] 🍥 chris-lamb.co.uk
       `-
diff --git debian/changelog debian/changelog
index 15fdf2e87..9acbd14c0 100644
--- debian/changelog
+++ debian/changelog
@@ -1,3 +1,27 @@
+python-django (3:3.2.25-0+deb12u2) bookworm-security; urgency=high
+
+  * 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: ASGIRequest allowed a remote attacker to cause a potential
+    denial-of-service via a crafted request with multiple duplicate headers.
+  * 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.
+
+ -- Chris Lamb <[email protected]>  Mon, 23 Feb 2026 15:32:59 -0800
+
 python-django (3:3.2.25-0+deb12u1) bookworm-security; urgency=high
 
   * Update to upstream's last 3.2 series release:
diff --git debian/patches/0034-CVE-2025-13473.patch 
debian/patches/0034-CVE-2025-13473.patch
new file mode 100644
index 000000000..55b50a0b4
--- /dev/null
+++ debian/patches/0034-CVE-2025-13473.patch
@@ -0,0 +1,118 @@
+From: Jake Howard <[email protected]>
+Date: Wed, 19 Nov 2025 16:52:28 +0000
+Subject: [PATCH] [4.2.x] 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 <[email protected]>
+
+Backport of 3eb814e02a4c336866d4189fa0c24fd1875863ed from main.
+---
+ django/contrib/auth/handlers/modwsgi.py | 37 ++++++++++++++++++++++++++-------
+ tests/auth_tests/test_handlers.py       | 26 +++++++++++++++++++++++
+ 2 files changed, 56 insertions(+), 7 deletions(-)
+
+diff --git a/django/contrib/auth/handlers/modwsgi.py 
b/django/contrib/auth/handlers/modwsgi.py
+index 591ec72cb4cd..086db89fc846 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/tests/auth_tests/test_handlers.py 
b/tests/auth_tests/test_handlers.py
+index 57a43f877f20..5b3a44d8f355 100644
+--- a/tests/auth_tests/test_handlers.py
++++ b/tests/auth_tests/test_handlers.py
+@@ -1,6 +1,9 @@
++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 debian/patches/0035-CVE-2025-14550.patch 
debian/patches/0035-CVE-2025-14550.patch
new file mode 100644
index 000000000..1038392c1
--- /dev/null
+++ debian/patches/0035-CVE-2025-14550.patch
@@ -0,0 +1,88 @@
+From: Jake Howard <[email protected]>
+Date: Wed, 14 Jan 2026 15:25:45 +0000
+Subject: [PATCH] [4.2.x] Fixed CVE-2025-14550 -- Optimized repeated header
+  parsing in ASGI requests.
+
+Thanks Jiyong Yang for the report, and Natalia Bidart, Jacob Walls, and
+Shai Berger for reviews.
+
+Backport of eb22e1d6d643360e952609ef562c139a100ea4eb from main.
+---
+ django/core/handlers/asgi.py |  7 ++++---
+ tests/asgi/tests.py          | 27 +++++++++++++++++++++++++++
+ 2 files changed, 31 insertions(+), 3 deletions(-)
+
+diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py
+index 7fbabe45104d..6f2976b544b5 100644
+--- a/django/core/handlers/asgi.py
++++ b/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 sync_to_async
+ 
+@@ -74,6 +75,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':
+@@ -85,9 +87,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 a/tests/asgi/tests.py b/tests/asgi/tests.py
+index 05ab0bc7854d..b59ad42678a8 100644
+--- a/tests/asgi/tests.py
++++ b/tests/asgi/tests.py
+@@ -9,6 +9,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 (
+@@ -240,3 +241,29 @@ class ASGITest(SimpleTestCase):
+         self.assertEqual(request_started_thread, request_finished_thread)
+         request_started.disconnect(signal_handler)
+         request_finished.disconnect(signal_handler)
++
++    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)
diff --git debian/patches/0036-CVE-2026-1207.patch 
debian/patches/0036-CVE-2026-1207.patch
new file mode 100644
index 000000000..b87be8941
--- /dev/null
+++ debian/patches/0036-CVE-2026-1207.patch
@@ -0,0 +1,101 @@
+From: Jacob Walls <[email protected]>
+Date: Mon, 19 Jan 2026 15:42:33 -0500
+Subject: [PATCH] [4.2.x] Fixed CVE-2026-1207 -- Prevented SQL injections in
+  RasterField lookups via band index.
+
+Thanks Tarek Nakkouch for the report, and Simon Charette for the initial
+triage and review.
+
+Backport of 81aa5292967cd09319c45fe2c1a525ce7b6684d8 from main.
+---
+ .../contrib/gis/db/backends/postgis/operations.py  |  6 +++
+ tests/gis_tests/rasterapp/test_rasterfield.py      | 47 +++++++++++++++++++++-
+ 2 files changed, 52 insertions(+), 1 deletion(-)
+
+diff --git a/django/contrib/gis/db/backends/postgis/operations.py 
b/django/contrib/gis/db/backends/postgis/operations.py
+index f068f28f48d6..244edbb5ac03 100644
+--- a/django/contrib/gis/db/backends/postgis/operations.py
++++ b/django/contrib/gis/db/backends/postgis/operations.py
+@@ -54,11 +54,17 @@ 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 only.')
+             template_params['lhs'] = '%s, %s' % (template_params['lhs'], 
lookup.band_lhs)
+ 
+         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 only.')
+             template_params['rhs'] = '%s, %s' % (template_params['rhs'], 
lookup.band_rhs)
+diff --git a/tests/gis_tests/rasterapp/test_rasterfield.py 
b/tests/gis_tests/rasterapp/test_rasterfield.py
+index 306bb85b196a..489f8359f72f 100644
+--- a/tests/gis_tests/rasterapp/test_rasterfield.py
++++ b/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
+@@ -307,6 +311,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 = 'IsValid function requires a GeometryField in position 1, got 
RasterField.'
diff --git debian/patches/0037-CVE-2026-1285.patch 
debian/patches/0037-CVE-2026-1285.patch
new file mode 100644
index 000000000..ff597ebb2
--- /dev/null
+++ debian/patches/0037-CVE-2026-1285.patch
@@ -0,0 +1,70 @@
+From: Natalia <[email protected]>
+Date: Wed, 21 Jan 2026 15:24:55 -0300
+Subject: [PATCH] [4.2.x] Fixed CVE-2026-1285 -- Mitigated potential DoS in
+  django.utils.text.Truncator for HTML input.
+
+The `TruncateHTMLParser` used `deque.remove()` to remove tags from the
+stack when processing end tags. With crafted input containing many
+unmatched end tags, this caused repeated full scans of the tag stack,
+leading to quadratic time complexity.
+
+The fix uses LIFO semantics, only removing a tag from the stack when it
+matches the most recently opened tag. This avoids linear scans for
+unmatched end tags and reduces complexity to linear time.
+
+Refs #30686 and 6ee37ada3241ed263d8d1c2901b030d964cbd161.
+
+Thanks Seokchan Yoon for the report.
+
+Backport of a33540b3e20b5d759aa8b2e4b9ca0e8edd285344 from main.
+---
+ django/utils/text.py           | 14 +++++---------
+ tests/utils_tests/test_text.py | 10 ++++++++++
+ 2 files changed, 15 insertions(+), 9 deletions(-)
+
+diff --git a/django/utils/text.py b/django/utils/text.py
+index cabd76f33f82..956d9d4a6f47 100644
+--- a/django/utils/text.py
++++ b/django/utils/text.py
+@@ -251,15 +251,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 a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
+index 758919c66e81..af3da791d074 100644
+--- a/tests/utils_tests/test_text.py
++++ b/tests/utils_tests/test_text.py
+@@ -91,6 +91,16 @@ class TestUtilsText(SimpleTestCase):
+         # lazy strings are handled correctly
+         self.assertEqual(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
diff --git debian/patches/0038-CVE-2026-1287.patch 
debian/patches/0038-CVE-2026-1287.patch
new file mode 100644
index 000000000..76bec85ed
--- /dev/null
+++ debian/patches/0038-CVE-2026-1287.patch
@@ -0,0 +1,291 @@
+From: Jake Howard <[email protected]>
+Date: Wed, 21 Jan 2026 11:14:48 +0000
+Subject: [PATCH] [4.2.x] Fixed CVE-2026-1287 -- Protected against SQL
+  injection in column aliases via control characters.
+
+Control characters in FilteredRelation column aliases could be used for
+SQL injection attacks. This affected QuerySet.annotate(), aggregate(),
+extra(), values(), values_list(), and alias() when using dictionary
+expansion with **kwargs.
+
+Thanks Solomon Kebede for the report, and Simon Charette, Jacob Walls,
+and Natalia Bidart for reviews.
+
+Backport of e891a84c7ef9962bfcc3b4685690219542f86a22 from main.
+---
+ django/db/models/sql/query.py             | 10 +++--
+ tests/aggregation/tests.py                | 18 ++++++---
+ tests/annotations/tests.py                | 66 ++++++++++++++++++++-----------
+ tests/expressions/test_queryset_values.py | 36 +++++++++++------
+ tests/queries/tests.py                    | 18 ++++++---
+ 5 files changed, 98 insertions(+), 50 deletions(-)
+
+diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py
+index 85dab7b47631..4f33ac0cdb30 100644
+--- a/django/db/models/sql/query.py
++++ b/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
+@@ -1052,7 +1054,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, is_summary=False, 
select=True):
+diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py
+index 827247721068..de8847b71688 100644
+--- a/tests/aggregation/tests.py
++++ b/tests/aggregation/tests.py
+@@ -1,6 +1,7 @@
+ import datetime
+ import re
+ from decimal import Decimal
++from itertools import chain
+ 
+ from django.core.exceptions import FieldError
+ from django.db import connection
+@@ -1369,10 +1370,15 @@ class AggregateTestCase(TestCase):
+         ], lambda a: (a.name, a.contact_count), ordered=False)
+ 
+     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")})
+diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py
+index 1d42e046def1..2060711cba36 100644
+--- a/tests/annotations/tests.py
++++ b/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
+@@ -769,31 +770,46 @@ 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."
++            "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.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.annotate(**{crafted_alias: 
FilteredRelation("author")})
+ 
+     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.alias(**{crafted_alias: 
FilteredRelation("authors")})
+ 
+     def test_alias_forbidden_chars(self):
+         tests = [
+@@ -811,10 +827,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):
+@@ -1058,13 +1075,18 @@ 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."
++            "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_dollar_sign(self):
+         qs = Book.objects.alias(
+diff --git a/tests/expressions/test_queryset_values.py 
b/tests/expressions/test_queryset_values.py
+index 97bfa107e07b..b84c3450a9e9 100644
+--- a/tests/expressions/test_queryset_values.py
++++ b/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
+ 
+@@ -27,26 +29,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 a/tests/queries/tests.py b/tests/queries/tests.py
+index e6ab6dffe814..66ab447f84f1 100644
+--- a/tests/queries/tests.py
++++ b/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
+ 
+@@ -1678,13 +1679,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"})
+ 
+ 
+ class SelectRelatedTests(TestCase):
diff --git debian/patches/0039-CVE-2026-1312.patch 
debian/patches/0039-CVE-2026-1312.patch
new file mode 100644
index 000000000..125e32a89
--- /dev/null
+++ debian/patches/0039-CVE-2026-1312.patch
@@ -0,0 +1,108 @@
+From: Jacob Walls <[email protected]>
+Date: Wed, 21 Jan 2026 17:53:52 -0500
+Subject: [PATCH] [4.2.x] Fixed CVE-2026-1312 -- Protected order_by() from SQL
+  injection via aliases with periods.
+
+Before, `order_by()` treated a period in a field name as a sign that it
+was requested via `.extra(order_by=...)` and thus should be passed
+through as raw table and column names, even if `extra()` was not used.
+Since periods are permitted in aliases, this meant user-controlled
+aliases could force the `order_by()` clause to resolve to a raw table
+and column pair instead of the actual target field for the alias.
+
+In practice, only `FilteredRelation` was affected, as the other
+expressions we tested, e.g. `F`, aggressively optimize away the ordering
+expressions into ordinal positions, e.g. ORDER BY 2, instead of ORDER BY
+"table".column.
+
+Thanks Solomon Kebede for the report, and Simon Charette and Jake Howard
+for reviews.
+
+Backport of 69065ca869b0970dff8fdd8fafb390bf8b3bf222 from main.
+---
+ django/db/models/sql/compiler.py |  2 +-
+ tests/ordering/tests.py          | 29 ++++++++++++++++++++++++++++-
+ tests/queries/tests.py           |  7 -------
+ 3 files changed, 29 insertions(+), 9 deletions(-)
+
+diff --git a/django/db/models/sql/compiler.py 
b/django/db/models/sql/compiler.py
+index a55e1d3c363c..11dce6b9540c 100644
+--- a/django/db/models/sql/compiler.py
++++ b/django/db/models/sql/compiler.py
+@@ -334,7 +334,7 @@ class SQLCompiler:
+                 order_by.append((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 a/tests/ordering/tests.py b/tests/ordering/tests.py
+index c8e9c98e437d..56df2883591c 100644
+--- a/tests/ordering/tests.py
++++ b/tests/ordering/tests.py
+@@ -2,10 +2,13 @@ from datetime import datetime
+ from operator import attrgetter
+ 
+ from django.db.models import (
+-    CharField, DateTimeField, F, Max, OuterRef, Subquery, Value,
++    CharField, DateTimeField, F, Max, OuterRef, Subquery, Value, 
FilteredRelation
+ )
+ from django.db.models.functions import Upper
++from django.db.utils import DatabaseError
+ from django.test import TestCase
++from django.test.utils import ignore_warnings
++from django.utils.deprecation import RemovedInDjango40Warning
+ 
+ from .models import Article, Author, ChildArticle, OrderedByFArticle, 
Reference
+ 
+@@ -311,6 +314,30 @@ class OrderingTests(TestCase):
+             attrgetter("headline")
+         )
+ 
++    @ignore_warnings(category=RemovedInDjango40Warning)
++    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")
++        qs2 = Article.objects.annotate(**{crafted: 
relation}).order_by(crafted)
++        with self.assertRaises(DatabaseError):
++            # 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.
++            self.assertNotEqual(qs2[0].headline, "Backdated")
++
+     def test_order_by_pk(self):
+         """
+         'pk' works as an ordering option in Meta.
+diff --git a/tests/queries/tests.py b/tests/queries/tests.py
+index 66ab447f84f1..4955a961fe0a 100644
+--- a/tests/queries/tests.py
++++ b/tests/queries/tests.py
+@@ -595,13 +595,6 @@ class Queries1Tests(TestCase):
+             [datetime.datetime(2007, 12, 19, 0, 0)],
+         )
+ 
+-    @ignore_warnings(category=RemovedInDjango40Warning)
+-    def test_ticket7098(self):
+-        self.assertSequenceEqual(
+-            Item.objects.values('note__note').order_by('queries_note.note', 
'id'),
+-            [{'note__note': 'n2'}, {'note__note': 'n3'}, {'note__note': 
'n3'}, {'note__note': 'n3'}]
+-        )
+-
+     def test_order_by_rawsql(self):
+         self.assertSequenceEqual(
+             Item.objects.values('note__note').order_by(
diff --git debian/patches/series debian/patches/series
index e7a89f47c..adc9a5cdb 100644
--- debian/patches/series
+++ debian/patches/series
@@ -29,3 +29,9 @@
 0030-CVE-2025-59682.patch
 0031-CVE-2025-64459.patch
 0032-CVE-2025-64460.patch
+0034-CVE-2025-13473.patch
+0035-CVE-2025-14550.patch
+0036-CVE-2026-1207.patch
+0037-CVE-2026-1285.patch
+0038-CVE-2026-1287.patch
+0039-CVE-2026-1312.patch

Reply via email to