From 3eb814e02a4c336866d4189fa0c24fd1875863ed Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Wed, 19 Nov 2025 16:52:28 +0000 Subject: [PATCH 01/10] 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> --- django/contrib/auth/handlers/modwsgi.py | 37 ++++++++++++++++++++----- docs/releases/4.2.28.txt | 10 +++++++ docs/releases/5.2.11.txt | 10 +++++++ docs/releases/6.0.2.txt | 10 +++++++ tests/auth_tests/test_handlers.py | 28 +++++++++++++++++++ 5 files changed, 88 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/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index 8c6d4a2a1d28..9f6d5cb152f1 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -7,3 +7,13 @@ Django 4.2.28 release notes Django 4.2.28 fixes three security issues with severity "high", two security issues with severity "moderate", and one security issue with severity "low" in 4.2.27. + +CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler +================================================================================================= + +The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for +:doc:`authentication via mod_wsgi` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index 545a7aeb7035..f975e45166ee 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -7,3 +7,13 @@ Django 5.2.11 release notes Django 5.2.11 fixes three security issues with severity "high", two security issues with severity "moderate", and one security issue with severity "low" in 5.2.10. + +CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler +================================================================================================= + +The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for +:doc:`authentication via mod_wsgi` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index 7dd10dbb4e25..ba39f74082c7 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -8,6 +8,16 @@ Django 6.0.2 fixes three security issues with severity "high", two security issues with severity "moderate", one security issue with severity "low", and several bugs in 6.0.1. +CVE-2025-13473: Username enumeration through timing difference in mod_wsgi authentication handler +================================================================================================= + +The ``django.contrib.auth.handlers.modwsgi.check_password()`` function for +:doc:`authentication via mod_wsgi` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/auth_tests/test_handlers.py b/tests/auth_tests/test_handlers.py index 77f37db00976..02743932df12 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() From eb22e1d6d643360e952609ef562c139a100ea4eb Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Wed, 14 Jan 2026 15:25:45 +0000 Subject: [PATCH 02/10] 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. --- django/core/handlers/asgi.py | 11 ++++++----- docs/releases/4.2.28.txt | 12 ++++++++++++ docs/releases/5.2.11.txt | 12 ++++++++++++ docs/releases/6.0.2.txt | 12 ++++++++++++ tests/asgi/tests.py | 28 +++++++++++++++++++++++++++- 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py index af8582d539e7..c8118e1691f9 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, closing 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": @@ -96,11 +98,10 @@ def __init__(self, scope, body_file): value = value.decode("latin1") if corrected_name == "HTTP_COOKIE": value = value.rstrip("; ") - if "HTTP_COOKIE" in self.META: - value = self.META[corrected_name] + "; " + value - elif corrected_name in self.META: - value = self.META[corrected_name] + "," + value - self.META[corrected_name] = value + _headers[corrected_name].append(value) + if cookie_header := _headers.pop("HTTP_COOKIE", None): + self.META["HTTP_COOKIE"] = "; ".join(cookie_header) + 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/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index 9f6d5cb152f1..67d398308cbb 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -17,3 +17,15 @@ allowed remote attackers to enumerate users via a timing attack. This issue has severity "low" according to the :ref:`Django security policy `. + +CVE-2025-14550: Potential denial-of-service vulnerability via repeated headers when using ASGI +============================================================================================== + +When receiving duplicates of a single header, ``ASGIRequest`` allowed a remote +attacker to cause a potential denial-of-service via a specifically created +request with multiple duplicate headers. The vulnerability resulted from +repeated string concatenation while combining repeated headers, which +produced super-linear computation resulting in service degradation or outage. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index f975e45166ee..1e5187d7ec5d 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -17,3 +17,15 @@ allowed remote attackers to enumerate users via a timing attack. This issue has severity "low" according to the :ref:`Django security policy `. + +CVE-2025-14550: Potential denial-of-service vulnerability via repeated headers when using ASGI +============================================================================================== + +When receiving duplicates of a single header, ``ASGIRequest`` allowed a remote +attacker to cause a potential denial-of-service via a specifically created +request with multiple duplicate headers. The vulnerability resulted from +repeated string concatenation while combining repeated headers, which +produced super-linear computation resulting in service degradation or outage. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index ba39f74082c7..a25825919529 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -18,6 +18,18 @@ allowed remote attackers to enumerate users via a timing attack. This issue has severity "low" according to the :ref:`Django security policy `. +CVE-2025-14550: Potential denial-of-service vulnerability via repeated headers when using ASGI +============================================================================================== + +When receiving duplicates of a single header, ``ASGIRequest`` allowed a remote +attacker to cause a potential denial-of-service via a specifically created +request with multiple duplicate headers. The vulnerability resulted from +repeated string concatenation while combining repeated headers, which +produced super-linear computation resulting in service degradation or outage. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index 0e23f7c24538..6a44d21d386e 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -223,7 +223,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) @@ -254,6 +254,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 From 81aa5292967cd09319c45fe2c1a525ce7b6684d8 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Mon, 19 Jan 2026 15:42:33 -0500 Subject: [PATCH 03/10] 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. --- .../gis/db/backends/postgis/operations.py | 6 +++ docs/releases/4.2.28.txt | 12 +++++ docs/releases/5.2.11.txt | 12 +++++ docs/releases/6.0.2.txt | 12 +++++ tests/gis_tests/rasterapp/test_rasterfield.py | 47 ++++++++++++++++++- 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/django/contrib/gis/db/backends/postgis/operations.py b/django/contrib/gis/db/backends/postgis/operations.py index e0a6dee7bee1..f4e70c9204f0 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/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index 67d398308cbb..aa0688280663 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -29,3 +29,15 @@ produced super-linear computation resulting in service degradation or outage. This issue has severity "moderate" according to the :ref:`Django security policy `. + +CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS +==================================================================== + +:ref:`Raster lookups ` on GIS fields (only implemented +on PostGIS) were subject to SQL injection if untrusted data was used as a band +index. + +As a reminder, all untrusted user input should be validated before use. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index 1e5187d7ec5d..73a0cd23b337 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -29,3 +29,15 @@ produced super-linear computation resulting in service degradation or outage. This issue has severity "moderate" according to the :ref:`Django security policy `. + +CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS +==================================================================== + +:ref:`Raster lookups ` on GIS fields (only implemented +on PostGIS) were subject to SQL injection if untrusted data was used as a band +index. + +As a reminder, all untrusted user input should be validated before use. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index a25825919529..8a694d443071 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -30,6 +30,18 @@ produced super-linear computation resulting in service degradation or outage. This issue has severity "moderate" according to the :ref:`Django security policy `. +CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS +==================================================================== + +:ref:`Raster lookups ` on GIS fields (only implemented +on PostGIS) were subject to SQL injection if untrusted data was used as a band +index. + +As a reminder, all untrusted user input should be validated before use. + +This issue has severity "high" according to the :ref:`Django security policy +`. + Bugfixes ======== 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 = ( From a33540b3e20b5d759aa8b2e4b9ca0e8edd285344 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 04/10] 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. --- django/utils/text.py | 9 +++++---- docs/releases/4.2.28.txt | 12 ++++++++++++ docs/releases/5.2.11.txt | 12 ++++++++++++ docs/releases/6.0.2.txt | 12 ++++++++++++ tests/utils_tests/test_text.py | 10 ++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/django/utils/text.py b/django/utils/text.py index baf44265a49c..ef4baa935bf2 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.append(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/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index aa0688280663..6ff358a8ecdb 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -41,3 +41,15 @@ As a reminder, all untrusted user input should be validated before use. This issue has severity "high" according to the :ref:`Django security policy `. + +CVE-2026-1285: Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` HTML methods +======================================================================================================== + +``django.utils.text.Truncator.chars()`` and ``Truncator.words()`` methods (with +``html=True``) and the :tfilter:`truncatechars_html` and +:tfilter:`truncatewords_html` template filters were subject to a potential +denial-of-service attack via certain inputs with a large number of unmatched +HTML end tags, which could cause quadratic time complexity during HTML parsing. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index 73a0cd23b337..bc5fb02063f0 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -41,3 +41,15 @@ As a reminder, all untrusted user input should be validated before use. This issue has severity "high" according to the :ref:`Django security policy `. + +CVE-2026-1285: Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` HTML methods +======================================================================================================== + +``django.utils.text.Truncator.chars()`` and ``Truncator.words()`` methods (with +``html=True``) and the :tfilter:`truncatechars_html` and +:tfilter:`truncatewords_html` template filters were subject to a potential +denial-of-service attack via certain inputs with a large number of unmatched +HTML end tags, which could cause quadratic time complexity during HTML parsing. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index 8a694d443071..0cb1037f86d9 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -42,6 +42,18 @@ As a reminder, all untrusted user input should be validated before use. This issue has severity "high" according to the :ref:`Django security policy `. +CVE-2026-1285: Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` HTML methods +======================================================================================================== + +``django.utils.text.Truncator.chars()`` and ``Truncator.words()`` methods (with +``html=True``) and the :tfilter:`truncatechars_html` and +:tfilter:`truncatewords_html` template filters were subject to a potential +denial-of-service attack via certain inputs with a large number of unmatched +HTML end tags, which could cause quadratic time complexity during HTML parsing. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== 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( From e891a84c7ef9962bfcc3b4685690219542f86a22 Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Wed, 21 Jan 2026 11:14:48 +0000 Subject: [PATCH 05/10] 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. --- django/db/models/sql/query.py | 23 ++++--- docs/releases/4.2.28.txt | 13 ++++ docs/releases/5.2.11.txt | 13 ++++ docs/releases/6.0.2.txt | 13 ++++ tests/aggregation/tests.py | 18 ++++-- tests/annotations/tests.py | 74 +++++++++++++++-------- tests/expressions/test_queryset_values.py | 36 +++++++---- tests/queries/tests.py | 18 ++++-- 8 files changed, 149 insertions(+), 59 deletions(-) diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py index 4be450167d78..c6f080dcbb3c 100644 --- a/django/db/models/sql/query.py +++ b/django/db/models/sql/query.py @@ -51,12 +51,17 @@ __all__ = ["Query", "RawQuery"] # RemovedInDjango70Warning: When the deprecation ends, replace with: -# Quotation marks ('"`[]), whitespace characters, semicolons, percent signs, -# hashes, or inline SQL comments are forbidden in column aliases. -# FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|%|#|--|/\*|\*/") -# 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, percent signs, hashes, or inline SQL comments are +# forbidden in column aliases. +# FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile( +# r"['`\"\]\[;\s\x00-\x1F\x7F-\x9F]|%|#|--|/\*|\*/" +# ) +# 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 @@ -1226,9 +1231,9 @@ def check_alias(self, alias): "Column aliases cannot contain whitespace characters, hashes, " # RemovedInDjango70Warning: When the deprecation ends, replace # with: - # "quotation marks, semicolons, percent signs, or SQL " - # "comments." - "quotation marks, semicolons, or SQL comments." + # "control characters, quotation marks, semicolons, percent " + # "signs, or SQL comments." + "control characters, quotation marks, semicolons, or SQL comments." ) def add_annotation(self, annotation, alias, select=True): diff --git a/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index 6ff358a8ecdb..473e44f57754 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -53,3 +53,16 @@ HTML end tags, which could cause quadratic time complexity during HTML parsing. This issue has severity "moderate" according to the :ref:`Django security policy `. + +CVE-2026-1287: Potential SQL injection in column aliases via control characters +=============================================================================== + +:class:`.FilteredRelation` was subject to SQL injection in column aliases via +control characters, using a suitably crafted dictionary, with dictionary +expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, +:meth:`~.QuerySet.aggregate`, :meth:`~.QuerySet.extra`, +:meth:`~.QuerySet.values`, :meth:`~.QuerySet.values_list`, and +:meth:`~.QuerySet.alias`. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index bc5fb02063f0..fa14a88c0a22 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -53,3 +53,16 @@ HTML end tags, which could cause quadratic time complexity during HTML parsing. This issue has severity "moderate" according to the :ref:`Django security policy `. + +CVE-2026-1287: Potential SQL injection in column aliases via control characters +=============================================================================== + +:class:`.FilteredRelation` was subject to SQL injection in column aliases via +control characters, using a suitably crafted dictionary, with dictionary +expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, +:meth:`~.QuerySet.aggregate`, :meth:`~.QuerySet.extra`, +:meth:`~.QuerySet.values`, :meth:`~.QuerySet.values_list`, and +:meth:`~.QuerySet.alias`. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index 0cb1037f86d9..884c873a6de4 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -54,6 +54,19 @@ HTML end tags, which could cause quadratic time complexity during HTML parsing. This issue has severity "moderate" according to the :ref:`Django security policy `. +CVE-2026-1287: Potential SQL injection in column aliases via control characters +=============================================================================== + +:class:`.FilteredRelation` was subject to SQL injection in column aliases via +control characters, using a suitably crafted dictionary, with dictionary +expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, +:meth:`~.QuerySet.aggregate`, :meth:`~.QuerySet.extra`, +:meth:`~.QuerySet.values`, :meth:`~.QuerySet.values_list`, and +:meth:`~.QuerySet.alias`. + +This issue has severity "high" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py index f2ec4bd3436c..bf6bf2703112 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 NotSupportedError, connection @@ -2242,13 +2243,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 69a2b4a7c7bc..42869bf1315b 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 @@ -1169,32 +1170,42 @@ def test_annotation_aggregate_with_m2o(self): ) def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( # "Column aliases cannot contain whitespace characters, hashes, " # "quotation marks, semicolons, percent signs, or SQL comments." # ) 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"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( # "Column aliases cannot contain whitespace characters, hashes, " # "quotation marks, semicolons, percent signs, or SQL comments." # ) 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 = [ @@ -1214,6 +1225,7 @@ def test_alias_forbidden_chars(self): "alias[", "alias]", "ali#as", + "ali\0as", ] # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( @@ -1221,8 +1233,8 @@ def test_alias_forbidden_chars(self): # "quotation marks, semicolons, percent signs, or SQL comments." # ) 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): @@ -1525,32 +1537,42 @@ 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"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( # "Column aliases cannot contain whitespace characters, hashes, " # "quotation marks, semicolons, percent signs, or SQL comments." # ) 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"; --""" # RemovedInDjango70Warning: When the deprecation ends, replace with: # msg = ( # "Column aliases cannot contain whitespace characters, hashes, " # "quotation marks, semicolons, percent signs, or SQL comments." # ) 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 24f22e818751..6264d0259450 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 from django.utils.deprecation import RemovedInDjango70Warning @@ -42,26 +44,36 @@ def test_values_expression_containing_percent_sign_deprecation_warns_once(self): self.assertEqual(len(cm.warnings), 1) 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 51d1915c97c5..74929e49440c 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 @@ -1965,13 +1966,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. From 69065ca869b0970dff8fdd8fafb390bf8b3bf222 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 21 Jan 2026 17:53:52 -0500 Subject: [PATCH 06/10] 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. --- django/db/models/sql/compiler.py | 2 +- docs/releases/4.2.28.txt | 10 ++++++++++ docs/releases/5.2.11.txt | 10 ++++++++++ docs/releases/6.0.2.txt | 10 ++++++++++ tests/ordering/tests.py | 25 +++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/django/db/models/sql/compiler.py b/django/db/models/sql/compiler.py index 1b1cd91c9a80..8742de00d69f 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/docs/releases/4.2.28.txt b/docs/releases/4.2.28.txt index 473e44f57754..1d81095b3eee 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -66,3 +66,13 @@ expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, This issue has severity "high" according to the :ref:`Django security policy `. + +CVE-2026-1312: Potential SQL injection via ``QuerySet.order_by`` and ``FilteredRelation`` +========================================================================================= + +:meth:`.QuerySet.order_by` was subject to SQL injection in column aliases +containing periods when the same alias was, using a suitably crafted +dictionary, with dictionary expansion, used in :class:`.FilteredRelation`. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/5.2.11.txt b/docs/releases/5.2.11.txt index fa14a88c0a22..76efc4aa8d25 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -66,3 +66,13 @@ expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, This issue has severity "high" according to the :ref:`Django security policy `. + +CVE-2026-1312: Potential SQL injection via ``QuerySet.order_by`` and ``FilteredRelation`` +========================================================================================= + +:meth:`.QuerySet.order_by` was subject to SQL injection in column aliases +containing periods when the same alias was, using a suitably crafted +dictionary, with dictionary expansion, used in :class:`.FilteredRelation`. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/6.0.2.txt b/docs/releases/6.0.2.txt index 884c873a6de4..0372cf9eb0c6 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -67,6 +67,16 @@ expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, This issue has severity "high" according to the :ref:`Django security policy `. +CVE-2026-1312: Potential SQL injection via ``QuerySet.order_by`` and ``FilteredRelation`` +========================================================================================= + +:meth:`.QuerySet.order_by` was subject to SQL injection in column aliases +containing periods when the same alias was, using a suitably crafted +dictionary, with dictionary expansion, used in :class:`.FilteredRelation`. + +This issue has severity "high" according to the :ref:`Django security policy +`. + Bugfixes ======== diff --git a/tests/ordering/tests.py b/tests/ordering/tests.py index 421689b9fa48..afe2e3c22cc6 100644 --- a/tests/ordering/tests.py +++ b/tests/ordering/tests.py @@ -8,6 +8,7 @@ Count, DateTimeField, F, + FilteredRelation, IntegerField, Max, OrderBy, @@ -17,6 +18,7 @@ When, ) from django.db.models.functions import Length, Upper +from django.db.utils import DatabaseError from django.test import TestCase from .models import ( @@ -395,6 +397,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. From 005d60d97c4dfb117503bdb6f2facfcaf9315d84 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Wed, 21 Jan 2026 18:00:13 -0500 Subject: [PATCH 07/10] 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. --- 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 c6f080dcbb3c..7a4cf843c1b1 100644 --- a/django/db/models/sql/query.py +++ b/django/db/models/sql/query.py @@ -1720,6 +1720,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 cdcd5c19afa8..e263307193cc 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 afe2e3c22cc6..008f0239b319 100644 --- a/tests/ordering/tests.py +++ b/tests/ordering/tests.py @@ -18,7 +18,6 @@ When, ) from django.db.models.functions import Length, Upper -from django.db.utils import DatabaseError from django.test import TestCase from .models import ( @@ -411,13 +410,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): From e7e43f1f91b5e4822ace888d85645eada8535daa Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 3 Feb 2026 09:05:18 -0500 Subject: [PATCH 08/10] Added stub release notes for 6.0.3. --- docs/releases/6.0.3.txt | 12 ++++++++++++ docs/releases/index.txt | 1 + 2 files changed, 13 insertions(+) create mode 100644 docs/releases/6.0.3.txt diff --git a/docs/releases/6.0.3.txt b/docs/releases/6.0.3.txt new file mode 100644 index 000000000000..eff44fe9dd6f --- /dev/null +++ b/docs/releases/6.0.3.txt @@ -0,0 +1,12 @@ +========================== +Django 6.0.3 release notes +========================== + +*Expected March 3, 2026* + +Django 6.0.3 fixes several bugs in 6.0.2. + +Bugfixes +======== + +* ... diff --git a/docs/releases/index.txt b/docs/releases/index.txt index fcb76a62476e..1ef9168032b2 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -32,6 +32,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 6.0.3 6.0.2 6.0.1 6.0 From af361d3be4725b9da1022c078b2db02b9d9b96e7 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 3 Feb 2026 09:11:06 -0500 Subject: [PATCH 09/10] Added CVE-2025-13473, CVE-2025-14550, CVE-2026-1207, CVE-2026-1285, CVE-2026-1287, and CVE-2026-1312 to security archive. --- docs/releases/security.txt | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/releases/security.txt b/docs/releases/security.txt index eacb5dbf2d3b..93f4209154e1 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,74 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +February 3, 2026 - :cve:`2025-13473` +------------------------------------ + +Username enumeration through timing difference in mod_wsgi authentication +handler. +`Full description +`__ + +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) <184e38ab0a061c365f5775676a074796d8abd02f>` +* Django 4.2 :commit:`(patch) <6dc23508f3395e1254c315084c7334ef81c4c09a>` + +February 3, 2026 - :cve:`2025-14550` +------------------------------------ + +Potential denial-of-service vulnerability via repeated headers when using ASGI. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <972dbdd4f7f69e9c405e6fe12a1b90e4713c1611>` +* Django 5.2 :commit:`(patch) <1ba90069c12836db46981bdf75b0e661db5849ce>` +* Django 4.2 :commit:`(patch) ` + +February 3, 2026 - :cve:`2026-1207` +----------------------------------- + +Potential SQL injection via raster lookups on PostGIS. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <8f77e7301174834573614ae90e1826fdf27f8a24>` +* Django 5.2 :commit:`(patch) <17a1d64a58ef24c0c3b78d66d86f5415075f18f0>` +* Django 4.2 :commit:`(patch) ` + +February 3, 2026 - :cve:`2026-1285` +----------------------------------- + +Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` +HTML methods. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <4b86ba51e486530db982341a23e53c7a1e1e6e71>` +* Django 5.2 :commit:`(patch) <9f2ada875bbee62ac46032e38ddb22755d67ae5a>` +* Django 4.2 :commit:`(patch) ` + +February 3, 2026 - :cve:`2026-1287` +----------------------------------- + +Potential SQL injection in column aliases via control characters. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <0c0f5c2178c01ada5410cd53b4b207bf7858b952>` +* Django 5.2 :commit:`(patch) <3e68ccdc11c127758745ddf0b4954990b14892bc>` +* Django 4.2 :commit:`(patch) ` + +February 3, 2026 - :cve:`2026-1312` +----------------------------------- + +Potential SQL injection via ``QuerySet.order_by`` and ``FilteredRelation``. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <15e70cb83e6f7a9a2a2f651f30b28b5cb20febeb>` +* Django 5.2 :commit:`(patch) ` +* Django 4.2 :commit:`(patch) <90f5b10784ba5bf369caed87640e2b4394ea3314>` + December 2, 2025 - :cve:`2025-13372` ------------------------------------ From 13299a6203f4bc3e5b2552c96a51ff2b15da3c43 Mon Sep 17 00:00:00 2001 From: jafarkhan83 Date: Mon, 2 Feb 2026 22:39:07 +0500 Subject: [PATCH 10/10] Fixed #36898 -- Documented SessionBase.is_empty(). --- docs/topics/http/sessions.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/topics/http/sessions.txt b/docs/topics/http/sessions.txt index 4cc5634780da..797b49ffa202 100644 --- a/docs/topics/http/sessions.txt +++ b/docs/topics/http/sessions.txt @@ -255,6 +255,10 @@ You can edit it multiple times. can't be accessed again from the user's browser (for example, the :func:`django.contrib.auth.logout` function calls it). + .. method:: is_empty() + + Returns ``True`` if the session does not have a key and is empty. + .. method:: set_test_cookie() .. method:: aset_test_cookie()