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/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/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/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/django/db/models/sql/query.py b/django/db/models/sql/query.py index 4be450167d78..7a4cf843c1b1 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): @@ -1715,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/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 8c6d4a2a1d28..1d81095b3eee 100644 --- a/docs/releases/4.2.28.txt +++ b/docs/releases/4.2.28.txt @@ -7,3 +7,72 @@ 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 +`. + +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 `. + +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 +`. + +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 `. + +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 +`. + +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 545a7aeb7035..76efc4aa8d25 100644 --- a/docs/releases/5.2.11.txt +++ b/docs/releases/5.2.11.txt @@ -7,3 +7,72 @@ 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 +`. + +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 `. + +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 +`. + +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 `. + +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 +`. + +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 7dd10dbb4e25..0372cf9eb0c6 100644 --- a/docs/releases/6.0.2.txt +++ b/docs/releases/6.0.2.txt @@ -8,6 +8,75 @@ 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 +`. + +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 `. + +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 +`. + +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 `. + +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 +`. + +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/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 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` ------------------------------------ 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() 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/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 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() 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/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/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/tests/ordering/tests.py b/tests/ordering/tests.py index 421689b9fa48..008f0239b319 100644 --- a/tests/ordering/tests.py +++ b/tests/ordering/tests.py @@ -8,6 +8,7 @@ Count, DateTimeField, F, + FilteredRelation, IntegerField, Max, OrderBy, @@ -395,6 +396,35 @@ 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") + 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): """ 'pk' works as an ordering option in Meta. 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. 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(