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