From 239aa42157245eaa580c1afe8562592cc8be37b9cd8bef67ab2a81fddd8078fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=C3=A9ta=20Machov=C3=A1?= Date: Wed, 4 Feb 2026 12:00:44 +0100 Subject: [PATCH 1/2] CVE-2025-13473 CVE-2025-14550 CVE-2026-1207 CVE-2026-1285 CVE-2026-1287 CVE-2026-1312 --- CVE-2025-13473.patch | 121 ++++++++++++++ CVE-2025-14550.patch | 95 +++++++++++ CVE-2026-1207.patch | 106 ++++++++++++ CVE-2026-1285.patch | 69 ++++++++ CVE-2026-1287.patch | 301 +++++++++++++++++++++++++++++++++++ CVE-2026-1312-followup.patch | 93 +++++++++++ CVE-2026-1312.patch | 92 +++++++++++ python-Django.changes | 12 ++ python-Django.spec | 14 ++ 9 files changed, 903 insertions(+) create mode 100644 CVE-2025-13473.patch create mode 100644 CVE-2025-14550.patch create mode 100644 CVE-2026-1207.patch create mode 100644 CVE-2026-1285.patch create mode 100644 CVE-2026-1287.patch create mode 100644 CVE-2026-1312-followup.patch create mode 100644 CVE-2026-1312.patch diff --git a/CVE-2025-13473.patch b/CVE-2025-13473.patch new file mode 100644 index 0000000..e442177 --- /dev/null +++ b/CVE-2025-13473.patch @@ -0,0 +1,121 @@ +From 184e38ab0a061c365f5775676a074796d8abd02f Mon Sep 17 00:00:00 2001 +From: Jake Howard +Date: Wed, 19 Nov 2025 16:52:28 +0000 +Subject: [PATCH] [5.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 ++++++++++++++++++++----- + docs/releases/4.2.28.txt | 10 +++++++ + docs/releases/5.2.11.txt | 10 +++++++ + tests/auth_tests/test_handlers.py | 28 +++++++++++++++++++ + 4 files changed, 78 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 @@ + 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 a6b53a9ef11d..32b4371198d4 100644 +--- a/tests/auth_tests/test_handlers.py ++++ b/tests/auth_tests/test_handlers.py +@@ -1,4 +1,7 @@ ++from unittest import mock ++ + from django.contrib.auth.handlers.modwsgi import check_password, groups_for_user ++from django.contrib.auth.hashers import get_hasher + from django.contrib.auth.models import Group, User + from django.test import TransactionTestCase, override_settings + +@@ -73,3 +76,28 @@ def test_groups_for_user(self): + + 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), ++ mock.patch.object(hasher, "encode") as mock_make_password, ++ ): ++ check_password({}, username, password) ++ mock_make_password.assert_called_once() diff --git a/CVE-2025-14550.patch b/CVE-2025-14550.patch new file mode 100644 index 0000000..19263ce --- /dev/null +++ b/CVE-2025-14550.patch @@ -0,0 +1,95 @@ +From 1ba90069c12836db46981bdf75b0e661db5849ce Mon Sep 17 00:00:00 2001 +From: Jake Howard +Date: Wed, 14 Jan 2026 15:25:45 +0000 +Subject: [PATCH] [5.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 ++++--- + docs/releases/4.2.28.txt | 12 ++++++++++++ + docs/releases/5.2.11.txt | 12 ++++++++++++ + tests/asgi/tests.py | 28 +++++++++++++++++++++++++++- + 4 files changed, 55 insertions(+), 4 deletions(-) + +diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py +index bb6a6bfb3ce7..2dfcc7f31d3f 100644 +--- a/django/core/handlers/asgi.py ++++ b/django/core/handlers/asgi.py +@@ -3,6 +3,7 @@ + import sys + import tempfile + import traceback ++from collections import defaultdict + from contextlib import aclosing + + from asgiref.sync import ThreadSensitiveContext, sync_to_async +@@ -83,6 +84,7 @@ def __init__(self, scope, body_file): + 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": +@@ -94,9 +96,8 @@ def __init__(self, scope, body_file): + # 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 0b1d3cd60879..81a53e539742 100644 +--- a/tests/asgi/tests.py ++++ b/tests/asgi/tests.py +@@ -211,7 +211,7 @@ async def test_post_body(self): + self.assertEqual(response_body["type"], "http.response.body") + self.assertEqual(response_body["body"], b"Echo!") + +- async def test_create_request_error(self): ++ async def test_request_too_big_request_error(self): + # Track request_finished signal. + signal_handler = SignalHandler() + request_finished.connect(signal_handler) +@@ -242,6 +242,32 @@ class TestASGIHandler(ASGIHandler): + signal_handler.calls[0]["thread"], threading.current_thread() + ) + ++ async def test_meta_not_modified_with_repeat_headers(self): ++ scope = self.async_request_factory._base_scope(path="/", http_version="2.0") ++ scope["headers"] = [(b"foo", b"bar")] * 200_000 ++ ++ setitem_count = 0 ++ ++ class InstrumentedDict(dict): ++ def __setitem__(self, *args, **kwargs): ++ nonlocal setitem_count ++ setitem_count += 1 ++ super().__setitem__(*args, **kwargs) ++ ++ class InstrumentedASGIRequest(ASGIRequest): ++ @property ++ def META(self): ++ return self._meta ++ ++ @META.setter ++ def META(self, value): ++ self._meta = InstrumentedDict(**value) ++ ++ request = InstrumentedASGIRequest(scope, None) ++ ++ self.assertEqual(len(request.headers["foo"].split(",")), 200_000) ++ self.assertLessEqual(setitem_count, 100) ++ + async def test_cancel_post_request_with_sync_processing(self): + """ + The request.body object should be available and readable in view diff --git a/CVE-2026-1207.patch b/CVE-2026-1207.patch new file mode 100644 index 0000000..efcbe5c --- /dev/null +++ b/CVE-2026-1207.patch @@ -0,0 +1,106 @@ +From 17a1d64a58ef24c0c3b78d66d86f5415075f18f0 Mon Sep 17 00:00:00 2001 +From: Jacob Walls +Date: Mon, 19 Jan 2026 15:42:33 -0500 +Subject: [PATCH] [5.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. +--- + .../gis/db/backends/postgis/operations.py | 6 +++ + docs/releases/4.2.28.txt | 12 +++++ + docs/releases/5.2.11.txt | 12 +++++ + tests/gis_tests/rasterapp/test_rasterfield.py | 47 ++++++++++++++++++- + 4 files changed, 76 insertions(+), 1 deletion(-) + +diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py +index 7a347c52878a..c39f756c792e 100644 +--- a/django/contrib/gis/db/backends/postgis/operations.py ++++ b/django/contrib/gis/db/backends/postgis/operations.py +@@ -51,6 +51,9 @@ def check_raster(self, lookup, template_params): + + # Look for band indices and inject them if provided. + if lookup.band_lhs is not None and lhs_is_raster: ++ if not isinstance(lookup.band_lhs, int): ++ name = lookup.band_lhs.__class__.__name__ ++ raise TypeError(f"Band index must be an integer, but got {name!r}.") + if not self.func: + raise ValueError( + "Band indices are not allowed for this operator, it works on bbox " +@@ -62,6 +65,9 @@ def check_raster(self, lookup, template_params): + ) + + if lookup.band_rhs is not None and rhs_is_raster: ++ if not isinstance(lookup.band_rhs, int): ++ name = lookup.band_rhs.__class__.__name__ ++ raise TypeError(f"Band index must be an integer, but got {name!r}.") + if not self.func: + raise ValueError( + "Band indices are not allowed for this operator, it works on bbox " +diff --git a/tests/gis_tests/rasterapp/test_rasterfield.py b/tests/gis_tests/rasterapp/test_rasterfield.py +index 3f2ce770a9a6..89c4ec4856a7 100644 +--- a/tests/gis_tests/rasterapp/test_rasterfield.py ++++ b/tests/gis_tests/rasterapp/test_rasterfield.py +@@ -2,7 +2,11 @@ + + from django.contrib.gis.db.models.fields import BaseSpatialField + from django.contrib.gis.db.models.functions import Distance +-from django.contrib.gis.db.models.lookups import DistanceLookupBase, GISLookup ++from django.contrib.gis.db.models.lookups import ( ++ DistanceLookupBase, ++ GISLookup, ++ RasterBandTransform, ++) + from django.contrib.gis.gdal import GDALRaster + from django.contrib.gis.geos import GEOSGeometry + from django.contrib.gis.measure import D +@@ -356,6 +360,47 @@ def test_lookup_input_band_not_allowed(self): + with self.assertRaisesMessage(ValueError, msg): + qs.count() + ++ def test_lookup_invalid_band_rhs(self): ++ rast = GDALRaster(json.loads(JSON_RASTER)) ++ qs = RasterModel.objects.filter(rast__contains=(rast, "evil")) ++ msg = "Band index must be an integer, but got 'str'." ++ with self.assertRaisesMessage(TypeError, msg): ++ qs.count() ++ ++ def test_lookup_invalid_band_lhs(self): ++ """ ++ Typical left-hand side usage is protected against non-integers, but for ++ defense-in-depth purposes, construct custom lookups that evade the ++ `int()` and `+ 1` checks in the lookups shipped by django.contrib.gis. ++ """ ++ ++ # Evade the int() call in RasterField.get_transform(). ++ class MyRasterBandTransform(RasterBandTransform): ++ band_index = "evil" ++ ++ def process_band_indices(self, *args, **kwargs): ++ self.band_lhs = self.lhs.band_index ++ self.band_rhs, *self.rhs_params = self.rhs_params ++ ++ # Evade the `+ 1` call in BaseSpatialField.process_band_indices(). ++ ContainsLookup = RasterModel._meta.get_field("rast").get_lookup("contains") ++ ++ class MyContainsLookup(ContainsLookup): ++ def process_band_indices(self, *args, **kwargs): ++ self.band_lhs = self.lhs.band_index ++ self.band_rhs, *self.rhs_params = self.rhs_params ++ ++ RasterField = RasterModel._meta.get_field("rast") ++ RasterField.register_lookup(MyContainsLookup, "contains") ++ self.addCleanup(RasterField.register_lookup, ContainsLookup, "contains") ++ ++ qs = RasterModel.objects.annotate( ++ transformed=MyRasterBandTransform("rast") ++ ).filter(transformed__contains=(F("transformed"), 1)) ++ msg = "Band index must be an integer, but got 'str'." ++ with self.assertRaisesMessage(TypeError, msg): ++ list(qs) ++ + def test_isvalid_lookup_with_raster_error(self): + qs = RasterModel.objects.filter(rast__isvalid=True) + msg = ( diff --git a/CVE-2026-1285.patch b/CVE-2026-1285.patch new file mode 100644 index 0000000..2116557 --- /dev/null +++ b/CVE-2026-1285.patch @@ -0,0 +1,69 @@ +From 9f2ada875bbee62ac46032e38ddb22755d67ae5a Mon Sep 17 00:00:00 2001 +From: Natalia <124304+nessita@users.noreply.github.com> +Date: Wed, 21 Jan 2026 09:53:10 -0300 +Subject: [PATCH] [5.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, and Jake Howard and Jacob Walls for +reviews. + +Backport of a33540b3e20b5d759aa8b2e4b9ca0e8edd285344 from main. +--- + django/utils/text.py | 9 +++++---- + docs/releases/4.2.28.txt | 12 ++++++++++++ + docs/releases/5.2.11.txt | 12 ++++++++++++ + tests/utils_tests/test_text.py | 10 ++++++++++ + 4 files changed, 39 insertions(+), 4 deletions(-) + +diff --git a/django/utils/text.py b/django/utils/text.py +index 26edde99e336..21efb00b98e8 100644 +--- a/django/utils/text.py ++++ b/django/utils/text.py +@@ -126,10 +126,11 @@ def handle_starttag(self, tag, attrs): + def handle_endtag(self, tag): + if tag not in self.void_elements: + self.output += f"" +- try: +- self.tags.remove(tag) +- except ValueError: +- pass ++ # Remove from the stack only if the tag matches the most recently ++ # opened tag (LIFO). This avoids O(n) linear scans for unmatched ++ # end tags if `deque.remove()` would be called. ++ if self.tags and self.tags[0] == tag: ++ self.tags.popleft() + + def handle_data(self, data): + data, output = self.process(data) +diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py +index 63c7889cbcec..11c01874cb5d 100644 +--- a/tests/utils_tests/test_text.py ++++ b/tests/utils_tests/test_text.py +@@ -202,6 +202,16 @@ def test_truncate_chars_html_with_html_entities(self): + truncator = text.Truncator("

I <3 python, what about you?

") + self.assertEqual("

I <3 python, wh…

", truncator.chars(16, html=True)) + ++ 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", ++ ) ++ + def test_truncate_words(self): + truncator = text.Truncator("The quick brown fox jumped over the lazy dog.") + self.assertEqual( diff --git a/CVE-2026-1287.patch b/CVE-2026-1287.patch new file mode 100644 index 0000000..d8809bc --- /dev/null +++ b/CVE-2026-1287.patch @@ -0,0 +1,301 @@ +From 3e68ccdc11c127758745ddf0b4954990b14892bc Mon Sep 17 00:00:00 2001 +From: Jake Howard +Date: Wed, 21 Jan 2026 11:14:48 +0000 +Subject: [PATCH] [5.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 +-- + docs/releases/4.2.28.txt | 13 ++++ + docs/releases/5.2.11.txt | 13 ++++ + tests/aggregation/tests.py | 18 ++++-- + tests/annotations/tests.py | 74 +++++++++++++++-------- + tests/expressions/test_queryset_values.py | 36 +++++++---- + tests/queries/tests.py | 18 ++++-- + 7 files changed, 128 insertions(+), 54 deletions(-) + +diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py +index 3a1cd739511e..baeac3a05be2 100644 +--- a/django/db/models/sql/query.py ++++ b/django/db/models/sql/query.py +@@ -48,9 +48,11 @@ + + __all__ = ["Query", "RawQuery"] + +-# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline +-# SQL comments are forbidden in column aliases. +-FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/") ++# Quotation marks ('"`[]), whitespace characters, control characters, ++# semicolons, hashes, or inline SQL comments are forbidden in column aliases. ++FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile( ++ r"['`\"\]\[;\s\x00-\x1F\x7F-\x9F]|#|--|/\*|\*/" ++) + + # Inspired from + # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS +@@ -1209,7 +1211,7 @@ def check_alias(self, alias): + if FORBIDDEN_ALIAS_PATTERN.search(alias): + raise ValueError( + "Column aliases cannot contain whitespace characters, hashes, " +- "quotation marks, semicolons, or SQL comments." ++ "control characters, quotation marks, semicolons, or SQL comments." + ) + + def add_annotation(self, annotation, alias, select=True): +diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py +index 2e41f1994723..f621c53e918a 100644 +--- a/tests/aggregation/tests.py ++++ b/tests/aggregation/tests.py +@@ -2,6 +2,7 @@ + import math + import re + from decimal import Decimal ++from itertools import chain + + from django.core.exceptions import FieldError + from django.db import connection +@@ -2134,13 +2135,18 @@ def test_exists_none_with_aggregate(self): + self.assertEqual(len(qs), 6) + + def test_alias_sql_injection(self): +- crafted_alias = """injected_name" from "aggregation_author"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, hashes, quotation " +- "marks, semicolons, or SQL comments." +- ) +- with self.assertRaisesMessage(ValueError, msg): +- Author.objects.aggregate(**{crafted_alias: Avg("age")}) ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "control characters, quotation marks, semicolons, or SQL comments." ++ ) ++ for crafted_alias in [ ++ """injected_name" from "aggregation_author"; --""", ++ # Control characters. ++ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), ++ ]: ++ with self.subTest(crafted_alias): ++ with self.assertRaisesMessage(ValueError, msg): ++ Author.objects.aggregate(**{crafted_alias: Avg("age")}) + + def test_exists_extra_where_with_aggregate(self): + qs = Book.objects.annotate( +diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py +index 78e5408d0fe7..0430c68f9175 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 unittest import skipUnless + + from django.core.exceptions import FieldDoesNotExist, FieldError +@@ -1157,22 +1158,32 @@ def test_annotation_aggregate_with_m2o(self): + ) + + def test_alias_sql_injection(self): +- crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, hashes, quotation " +- "marks, semicolons, or SQL comments." +- ) +- with self.assertRaisesMessage(ValueError, msg): +- Book.objects.annotate(**{crafted_alias: Value(1)}) ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "control characters, quotation marks, semicolons, or SQL comments." ++ ) ++ for crafted_alias in [ ++ """injected_name" from "annotations_book"; --""", ++ # Control characters. ++ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), ++ ]: ++ with self.subTest(crafted_alias): ++ with self.assertRaisesMessage(ValueError, msg): ++ Book.objects.annotate(**{crafted_alias: Value(1)}) + + def test_alias_filtered_relation_sql_injection(self): +- crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, hashes, quotation " +- "marks, semicolons, or SQL comments." +- ) +- with self.assertRaisesMessage(ValueError, msg): +- Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "control characters, quotation marks, semicolons, or SQL comments." ++ ) ++ for crafted_alias in [ ++ """injected_name" from "annotations_book"; --""", ++ # Control characters. ++ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), ++ ]: ++ with self.subTest(crafted_alias): ++ with self.assertRaisesMessage(ValueError, msg): ++ Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) + + def test_alias_forbidden_chars(self): + tests = [ +@@ -1190,10 +1201,11 @@ def test_alias_forbidden_chars(self): + "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): +@@ -1491,22 +1503,32 @@ def test_alias_after_values(self): + self.assertEqual(qs.get(pk=self.b1.pk), (self.b1.pk,)) + + def test_alias_sql_injection(self): +- crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, hashes, quotation " +- "marks, semicolons, or SQL comments." +- ) +- with self.assertRaisesMessage(ValueError, msg): +- Book.objects.alias(**{crafted_alias: Value(1)}) ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "control characters, quotation marks, semicolons, or SQL comments." ++ ) ++ for crafted_alias in [ ++ """injected_name" from "annotations_book"; --""", ++ # Control characters. ++ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), ++ ]: ++ with self.subTest(crafted_alias): ++ with self.assertRaisesMessage(ValueError, msg): ++ Book.objects.alias(**{crafted_alias: Value(1)}) + + def test_alias_filtered_relation_sql_injection(self): +- crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, hashes, quotation " +- "marks, semicolons, or SQL comments." +- ) +- with self.assertRaisesMessage(ValueError, msg): +- Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "control characters, quotation marks, semicolons, or SQL comments." ++ ) ++ for crafted_alias in [ ++ """injected_name" from "annotations_book"; --""", ++ # Control characters. ++ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), ++ ]: ++ with self.subTest(crafted_alias): ++ with self.assertRaisesMessage(ValueError, msg): ++ Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) + + def test_alias_filtered_relation_sql_injection_dollar_sign(self): + qs = Book.objects.alias( +diff --git a/tests/expressions/test_queryset_values.py b/tests/expressions/test_queryset_values.py +index 080ee06183dc..afd8a51159d5 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 + +@@ -35,26 +37,36 @@ def test_values_expression(self): + ) + + 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 c1589669b073..69d728b1cd36 100644 +--- a/tests/queries/tests.py ++++ b/tests/queries/tests.py +@@ -2,6 +2,7 @@ + import pickle + import sys + import unittest ++from itertools import chain + from operator import attrgetter + + from django.core.exceptions import EmptyResultSet, FieldError, FullResultSet +@@ -1959,13 +1960,18 @@ def test_extra_select_literal_percent_s(self): + ) + + def test_extra_select_alias_sql_injection(self): +- crafted_alias = """injected_name" from "queries_note"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, hashes, quotation " +- "marks, semicolons, or SQL comments." +- ) +- with self.assertRaisesMessage(ValueError, msg): +- Note.objects.extra(select={crafted_alias: "1"}) ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "control characters, quotation marks, semicolons, or SQL comments." ++ ) ++ for crafted_alias in [ ++ """injected_name" from "queries_note"; --""", ++ # Control characters. ++ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), ++ ]: ++ with self.subTest(crafted_alias): ++ with self.assertRaisesMessage(ValueError, msg): ++ Note.objects.extra(select={crafted_alias: "1"}) + + def test_queryset_reuse(self): + # Using querysets doesn't mutate aliases. diff --git a/CVE-2026-1312-followup.patch b/CVE-2026-1312-followup.patch new file mode 100644 index 0000000..c61839e --- /dev/null +++ b/CVE-2026-1312-followup.patch @@ -0,0 +1,93 @@ +From ab0ad8d39555292b55123adeac57ed64c776f8d9 Mon Sep 17 00:00:00 2001 +From: Jacob Walls +Date: Wed, 21 Jan 2026 18:00:13 -0500 +Subject: [PATCH] [5.2.x] Refs CVE-2026-1312 -- Raised ValueError when + FilteredRelation aliases contain periods. + +This prevents failures at the database layer, given that aliases in the +ON clause are not quoted. + +Systematically quoting aliases even in FilteredRelation is tracked in +https://code.djangoproject.com/ticket/36795. + +Backport of 005d60d97c4dfb117503bdb6f2facfcaf9315d84 from main. +--- + django/db/models/sql/query.py | 5 +++++ + tests/filtered_relation/tests.py | 13 +++++++++++++ + tests/ordering/tests.py | 11 ++++++++--- + 3 files changed, 26 insertions(+), 3 deletions(-) + +diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py +index baeac3a05be2..324e605b8eea 100644 +--- a/django/db/models/sql/query.py ++++ b/django/db/models/sql/query.py +@@ -1698,6 +1698,11 @@ def _add_q( + return target_clause, needed_inner + + def add_filtered_relation(self, filtered_relation, alias): ++ if "." in alias: ++ raise ValueError( ++ "FilteredRelation doesn't support aliases with periods " ++ "(got %r)." % alias ++ ) + self.check_alias(alias) + filtered_relation.alias = alias + relation_lookup_parts, relation_field_parts, _ = self.solve_lookup_type( +diff --git a/tests/filtered_relation/tests.py b/tests/filtered_relation/tests.py +index a9c6e00567cf..a4561d59f95f 100644 +--- a/tests/filtered_relation/tests.py ++++ b/tests/filtered_relation/tests.py +@@ -216,6 +216,19 @@ def test_internal_queryset_alias_mapping(self): + str(queryset.query), + ) + ++ def test_period_forbidden(self): ++ msg = ( ++ "FilteredRelation doesn't support aliases with periods (got 'book.alice')." ++ ) ++ with self.assertRaisesMessage(ValueError, msg): ++ Author.objects.annotate( ++ **{ ++ "book.alice": FilteredRelation( ++ "book", condition=Q(book__title__iexact="poem by alice") ++ ) ++ } ++ ) ++ + def test_multiple(self): + qs = ( + Author.objects.annotate( +diff --git a/tests/ordering/tests.py b/tests/ordering/tests.py +index 530a27920e9d..30da025875bd 100644 +--- a/tests/ordering/tests.py ++++ b/tests/ordering/tests.py +@@ -15,7 +15,6 @@ + Value, + ) + from django.db.models.functions import Length, Upper +-from django.db.utils import DatabaseError + from django.test import TestCase + + from .models import ( +@@ -408,13 +407,19 @@ def test_alias_with_period_shadows_table_name(self): + self.assertNotEqual(qs[0].headline, "Backdated") + + relation = FilteredRelation("author") +- qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted) +- with self.assertRaises(DatabaseError): ++ msg = ( ++ "FilteredRelation doesn't support aliases with periods " ++ "(got 'ordering_article.pub_date')." ++ ) ++ with self.assertRaisesMessage(ValueError, msg): ++ qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted) + # Before, unlike F(), which causes ordering expressions to be + # replaced by ordinals like n in ORDER BY n, these were ordered by + # pub_date instead of author. + # The Article model orders by -pk, so sorting on author will place + # first any article by author2 instead of the backdated one. ++ # This assertion is reachable if FilteredRelation.__init__() starts ++ # supporting periods in aliases in the future. + self.assertNotEqual(qs2[0].headline, "Backdated") + + def test_order_by_pk(self): diff --git a/CVE-2026-1312.patch b/CVE-2026-1312.patch new file mode 100644 index 0000000..51816ab --- /dev/null +++ b/CVE-2026-1312.patch @@ -0,0 +1,92 @@ +From e863ee273c6553e9b6fa4960a17acb535851857b Mon Sep 17 00:00:00 2001 +From: Jacob Walls +Date: Wed, 21 Jan 2026 17:53:52 -0500 +Subject: [PATCH] [5.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 +- + docs/releases/4.2.28.txt | 10 ++++++++++ + docs/releases/5.2.11.txt | 10 ++++++++++ + tests/ordering/tests.py | 25 +++++++++++++++++++++++++ + 4 files changed, 46 insertions(+), 1 deletion(-) + +diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py +index 4292243c71ee..7dbdec363568 100644 +--- a/django/db/models/sql/compiler.py ++++ b/django/db/models/sql/compiler.py +@@ -433,7 +433,7 @@ def _order_by_pairs(self): + yield OrderBy(expr, descending=descending), False + continue + +- if "." in field: ++ if "." in field and field in self.query.extra_order_by: + # This came in through an extra(order_by=...) addition. Pass it + # on verbatim. + table, col = col.split(".", 1) +diff --git a/tests/ordering/tests.py b/tests/ordering/tests.py +index b29404ed77da..530a27920e9d 100644 +--- a/tests/ordering/tests.py ++++ b/tests/ordering/tests.py +@@ -7,6 +7,7 @@ + Count, + DateTimeField, + F, ++ FilteredRelation, + Max, + OrderBy, + OuterRef, +@@ -14,6 +15,7 @@ + Value, + ) + from django.db.models.functions import Length, Upper ++from django.db.utils import DatabaseError + from django.test import TestCase + + from .models import ( +@@ -392,6 +394,29 @@ def test_extra_ordering_with_table_name(self): + attrgetter("headline"), + ) + ++ def test_alias_with_period_shadows_table_name(self): ++ """ ++ Aliases with periods are not confused for table names from extra(). ++ """ ++ Article.objects.update(author=self.author_2) ++ Article.objects.create( ++ headline="Backdated", pub_date=datetime(1900, 1, 1), author=self.author_1 ++ ) ++ crafted = "ordering_article.pub_date" ++ ++ qs = Article.objects.annotate(**{crafted: F("author")}).order_by("-" + crafted) ++ self.assertNotEqual(qs[0].headline, "Backdated") ++ ++ relation = FilteredRelation("author") ++ 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/python-Django.changes b/python-Django.changes index d17b96b..2e15fe1 100644 --- a/python-Django.changes +++ b/python-Django.changes @@ -1,3 +1,15 @@ +------------------------------------------------------------------- +Wed Feb 4 11:00:19 UTC 2026 - Markéta Machová + +- Add security patches: + * CVE-2025-14550.patch (bsc#1257403) + * CVE-2026-1312.patch (bsc#1257408) + * CVE-2026-1312-followup.patch (bsc#1257408) + * CVE-2026-1287.patch (bsc#1257407) + * CVE-2026-1207.patch (bsc#1257405) + * CVE-2025-13473.patch (bsc#1257401) + * CVE-2026-1285.patch (bsc#1257406) + ------------------------------------------------------------------- Mon Dec 8 11:04:00 UTC 2025 - Markéta Machová diff --git a/python-Django.spec b/python-Django.spec index f762fe0..c26bfe9 100644 --- a/python-Django.spec +++ b/python-Django.spec @@ -44,6 +44,20 @@ Patch4: CVE-2025-64459.patch Patch5: CVE-2025-13372.patch # PATCH-FIX-UPSTREAM CVE-2025-64460.patch bsc#1254437 Patch6: CVE-2025-64460.patch +# PATCH-FIX-UPSTREAM CVE-2025-13473.patch bsc#1257401 +Patch7: CVE-2025-13473.patch +# PATCH-FIX-UPSTREAM CVE-2025-14550.patch bsc#1257403 +Patch8: CVE-2025-14550.patch +# PATCH-FIX-UPSTREAM CVE-2026-1207.patch bsc#1257405 +Patch9: CVE-2026-1207.patch +# PATCH-FIX-UPSTREAM CVE-2026-1285.patch bsc#1257406 +Patch10: CVE-2026-1285.patch +# PATCH-FIX-UPSTREAM CVE-2026-1287.patch bsc#1257407 +Patch11: CVE-2026-1287.patch +# PATCH-FIX-UPSTREAM CVE-2026-1312.patch bsc#1257408 +Patch12: CVE-2026-1312.patch +# PATCH-FIX-UPSTREAM CVE-2026-1312-followup.patch bsc#1257408 +Patch13: CVE-2026-1312-followup.patch BuildRequires: %{python_module Jinja2 >= 2.9.2} BuildRequires: %{python_module Pillow >= 6.2.0} BuildRequires: %{python_module PyYAML} -- 2.51.1 From 53638080fdf7438db3f085abcc981fc604e07aea7e5dacfe4499e2c07e1c3624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=C3=A9ta=20Machov=C3=A1?= Date: Wed, 4 Feb 2026 14:29:48 +0100 Subject: [PATCH 2/2] Add test_strip_tags_incomplete.patch --- python-Django.changes | 7 +++++++ python-Django.spec | 5 ++++- test_strip_tags.patch | 22 +++++++++++----------- test_strip_tags_incomplete.patch | 24 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 test_strip_tags_incomplete.patch diff --git a/python-Django.changes b/python-Django.changes index 2e15fe1..639fb27 100644 --- a/python-Django.changes +++ b/python-Django.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Wed Feb 4 13:21:44 UTC 2026 - Markéta Machová + +- Add test_strip_tags_incomplete.patch to fix behaviour with changes + in the Python interpreter +- Rebase test_strip_tags.patch + ------------------------------------------------------------------- Wed Feb 4 11:00:19 UTC 2026 - Markéta Machová diff --git a/python-Django.spec b/python-Django.spec index c26bfe9..248939a 100644 --- a/python-Django.spec +++ b/python-Django.spec @@ -30,8 +30,11 @@ Source: https://www.djangoproject.com/m/releases/5.2/django-%{version}.t Source1: https://www.djangoproject.com/m/pgp/Django-%{version}.checksum.txt Source2: %{name}.keyring Source99: python-Django-rpmlintrc +# PATCH-FIX-UPSTREAM https://github.com/django/django/pull/20390 Refs #36499 -- Adjusted test_strip_tags following Python behavior change for incomplete entities. +Patch100: test_strip_tags_incomplete.patch # PATCH-FIX-UPSTREAM https://github.com/django/django/pull/19639 Fixed #36499 -- Adjusted utils_tests.test_html.TestUtilsHtml.test_strip_tags following Python's HTMLParser new behavior. -Patch0: test_strip_tags.patch +# fixed and refined upstream, but some of our interpreters weren't updated to a new version yet and still only carry the patch, so providing the non-conditional version +Patch101: test_strip_tags.patch # PATCH-FIX-UPSTREAM CVE-2025-57833.patch bsc#1248810 Patch1: CVE-2025-57833.patch # PATCH-FIX-UPSTREAM CVE-2025-59681.patch bsc#1250485 diff --git a/test_strip_tags.patch b/test_strip_tags.patch index 2f1363f..28e5e29 100644 --- a/test_strip_tags.patch +++ b/test_strip_tags.patch @@ -10,11 +10,11 @@ Subject: [PATCH] Fixed #36499 -- Adjusted tests/utils_tests/test_html.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) -diff --git a/tests/test_utils/tests.py b/tests/test_utils/tests.py -index 494a0ea8d384..0de19eae9072 100644 ---- a/tests/test_utils/tests.py -+++ b/tests/test_utils/tests.py -@@ -959,10 +959,10 @@ def test_parsing_errors(self): +Index: django-5.2.4/tests/test_utils/tests.py +=================================================================== +--- django-5.2.4.orig/tests/test_utils/tests.py ++++ django-5.2.4/tests/test_utils/tests.py +@@ -945,10 +945,10 @@ class HTMLEqualTests(SimpleTestCase): self.assertHTMLEqual("", "

") error_msg = ( "First argument is not valid HTML:\n" @@ -27,14 +27,14 @@ index 494a0ea8d384..0de19eae9072 100644 with self.assertRaises(HTMLParseError): parse_html("

") -diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py -index 4ce552e79a0d..205eaeca1668 100644 ---- a/tests/utils_tests/test_html.py -+++ b/tests/utils_tests/test_html.py -@@ -142,10 +142,10 @@ def test_strip_tags(self): +Index: django-5.2.4/tests/utils_tests/test_html.py +=================================================================== +--- django-5.2.4.orig/tests/utils_tests/test_html.py ++++ django-5.2.4/tests/utils_tests/test_html.py +@@ -144,10 +144,10 @@ class TestUtilsHtml(SimpleTestCase): ("&gotcha&#;<>", "&gotcha&#;<>"), ("ript>test</script>", "ript>test"), - ("&h", "alert()h"), + ("&h", "alert()&h;"), - (">"), ("X<<<
br>br>br>X", "XX"), diff --git a/test_strip_tags_incomplete.patch b/test_strip_tags_incomplete.patch new file mode 100644 index 0000000..d6be989 --- /dev/null +++ b/test_strip_tags_incomplete.patch @@ -0,0 +1,24 @@ +From 5ca0f62213911a77dd4a62e843db7e420cc98b78 Mon Sep 17 00:00:00 2001 +From: Jacob Walls +Date: Thu, 11 Dec 2025 08:44:19 -0500 +Subject: [PATCH] [5.2.x] Refs #36499 -- Adjusted test_strip_tags following + Python behavior change for incomplete entities. + +Backport of 7b80b2186300620931009fd62c2969f108fe7a62 from main. +--- + tests/utils_tests/test_html.py | 35 +++++++++++++++++++++++++++++----- + 1 file changed, 30 insertions(+), 5 deletions(-) + +Index: django-5.2.4/tests/utils_tests/test_html.py +=================================================================== +--- django-5.2.4.orig/tests/utils_tests/test_html.py ++++ django-5.2.4/tests/utils_tests/test_html.py +@@ -143,7 +144,7 @@ class TestUtilsHtml(SimpleTestCase): + # https://bugs.python.org/issue20288 + ("&gotcha&#;<>", "&gotcha&#;<>"), + ("ript>test</script>", "ript>test"), +- ("&h", "alert()h"), ++ ("&h", "alert()&h;"), + (">br>br>br>X", "XX"), + ("<" * 50 + "a>" * 50, ""), -- 2.51.1