Version in base suite: 3.2.19-1+deb12u1 Version in overlay suite: 3.2.25-0+deb12u1 Base version: python-django_3.2.25-0+deb12u1 Target version: python-django_3.2.25-0+deb12u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-django/python-django_3.2.25-0+deb12u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-django/python-django_3.2.25-0+deb12u2.dsc changelog | 24 +++ patches/0034-CVE-2025-13473.patch | 118 +++++++++++++++ patches/0035-CVE-2025-14550.patch | 88 +++++++++++ patches/0036-CVE-2026-1207.patch | 101 +++++++++++++ patches/0037-CVE-2026-1285.patch | 70 +++++++++ patches/0038-CVE-2026-1287.patch | 291 ++++++++++++++++++++++++++++++++++++++ patches/0039-CVE-2026-1312.patch | 108 ++++++++++++++ patches/series | 6 8 files changed, 806 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpvyrmgg45/python-django_3.2.25-0+deb12u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpvyrmgg45/python-django_3.2.25-0+deb12u2.dsc: no acceptable signature found diff -Nru python-django-3.2.25/debian/changelog python-django-3.2.25/debian/changelog --- python-django-3.2.25/debian/changelog 2026-01-27 19:16:59.000000000 +0000 +++ python-django-3.2.25/debian/changelog 2026-02-23 23:32:59.000000000 +0000 @@ -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 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 -Nru python-django-3.2.25/debian/patches/0034-CVE-2025-13473.patch python-django-3.2.25/debian/patches/0034-CVE-2025-13473.patch --- python-django-3.2.25/debian/patches/0034-CVE-2025-13473.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0034-CVE-2025-13473.patch 2026-02-23 23:32:59.000000000 +0000 @@ -0,0 +1,118 @@ +From: Jake Howard +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 <124304+nessita@users.noreply.github.com> + +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", "test@example.com", "test") ++ User.objects.create_user("inactive", "test@nono.com", "test", is_active=False) ++ User.objects.create_user("unusable", "test@nono.com") ++ ++ hasher = get_hasher() ++ ++ for username, password in [ ++ ("test", "test"), ++ ("test", "wrong"), ++ ("inactive", "test"), ++ ("inactive", "wrong"), ++ ("unusable", "test"), ++ ("doesnotexist", "test"), ++ ]: ++ with self.subTest(username=username, password=password): ++ with mock.patch.object(hasher, "encode") as mock_make_password: ++ check_password({}, username, password) ++ mock_make_password.assert_called_once() diff -Nru python-django-3.2.25/debian/patches/0035-CVE-2025-14550.patch python-django-3.2.25/debian/patches/0035-CVE-2025-14550.patch --- python-django-3.2.25/debian/patches/0035-CVE-2025-14550.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0035-CVE-2025-14550.patch 2026-02-23 23:32:59.000000000 +0000 @@ -0,0 +1,88 @@ +From: Jake Howard +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 -Nru python-django-3.2.25/debian/patches/0036-CVE-2026-1207.patch python-django-3.2.25/debian/patches/0036-CVE-2026-1207.patch --- python-django-3.2.25/debian/patches/0036-CVE-2026-1207.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0036-CVE-2026-1207.patch 2026-02-23 23:32:59.000000000 +0000 @@ -0,0 +1,101 @@ +From: Jacob Walls +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 -Nru python-django-3.2.25/debian/patches/0037-CVE-2026-1285.patch python-django-3.2.25/debian/patches/0037-CVE-2026-1285.patch --- python-django-3.2.25/debian/patches/0037-CVE-2026-1285.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0037-CVE-2026-1285.patch 2026-02-23 23:32:59.000000000 +0000 @@ -0,0 +1,70 @@ +From: Natalia <124304+nessita@users.noreply.github.com> +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…') + ++ def test_truncate_chars_html_with_misnested_tags(self): ++ # LIFO removal keeps all tags when a middle tag is closed out of order. ++ # With , the doesn't match , so all tags remain ++ # in the stack and are properly closed at truncation. ++ truncator = text.Truncator("XXXX") ++ self.assertEqual( ++ truncator.chars(2, html=True, truncate=""), ++ "XX", ++ ) ++ + @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000) + def test_truncate_chars_html_size_limit(self): + max_len = text.Truncator.MAX_LENGTH_HTML diff -Nru python-django-3.2.25/debian/patches/0038-CVE-2026-1287.patch python-django-3.2.25/debian/patches/0038-CVE-2026-1287.patch --- python-django-3.2.25/debian/patches/0038-CVE-2026-1287.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0038-CVE-2026-1287.patch 2026-02-23 23:32:59.000000000 +0000 @@ -0,0 +1,291 @@ +From: Jake Howard +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 -Nru python-django-3.2.25/debian/patches/0039-CVE-2026-1312.patch python-django-3.2.25/debian/patches/0039-CVE-2026-1312.patch --- python-django-3.2.25/debian/patches/0039-CVE-2026-1312.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0039-CVE-2026-1312.patch 2026-02-23 23:32:59.000000000 +0000 @@ -0,0 +1,108 @@ +From: Jacob Walls +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 -Nru python-django-3.2.25/debian/patches/series python-django-3.2.25/debian/patches/series --- python-django-3.2.25/debian/patches/series 2026-01-27 19:16:59.000000000 +0000 +++ python-django-3.2.25/debian/patches/series 2026-02-23 23:32:59.000000000 +0000 @@ -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