Hi security team,
Based on your previous preference for a DSA (over PU) for Django,
I have prepared an update for python-django (3:4.2.28-0+deb13u1)
intended for trixie:
python-django (3:4.2.28-0+deb13u1) trixie-security; urgency=high
.
* New upstream security release:
.
- CVE-2025-13473: The check_password function in
django.contrib.auth.handlers.modwsgi for authentication via mod_wsgi
allowed remote attackers to enumerate users via a timing attack.
.
- CVE-2025-14550: 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.
.
- CVE-2026-1207: Raster lookups on RasterField (only implemented on
PostGIS) allowed remote attackers to inject SQL via the band index
parameter.
.
- CVE-2026-1285: The django.utils.text.Truncator.chars() and
Truncator.words() methods (with html=True) and the truncatechars_html
and
truncatewords_html template filters allowed a remote attacker to cause a
potential denial-of-service via crafted inputs containing a large number
of unmatched HTML end tags.
.
- CVE-2026-1287: FilteredRelation was subject to SQL injection in column
aliases via control characters using a suitably crafted dictionary, with
dictionary expansion, as the **kwargs passed to QuerySet methods
annotate(), aggregate(), extra(), values(), values_list() and alias().
.
- CVE-2026-1312: QuerySet.order_by() was subject to SQL injection in
column
aliases containing periods when the same alias is, using a suitably
crafted dictionary, with dictionary expansion, used in FilteredRelation.
.
<https://docs.djangoproject.com/en/dev/releases/4.2.28/> (Closes:
#1126914)
The full diff is attached. Let me know if I should file this as a
PU instead.
Regards,
--
,''`.
: :' : Chris Lamb
`. `'` [email protected] / chris-lamb.co.uk
`-
diff --git debian/changelog debian/changelog
index bf64b1674..5247a7def 100644
--- debian/changelog
+++ debian/changelog
@@ -1,3 +1,41 @@
+python-django (3:4.2.28-0+deb13u1) trixie-security; urgency=high
+
+ * New upstream security release:
+
+ - CVE-2025-13473: The check_password function in
+ django.contrib.auth.handlers.modwsgi for authentication via mod_wsgi
+ allowed remote attackers to enumerate users via a timing attack.
+
+ - CVE-2025-14550: 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.
+
+ - CVE-2026-1207: Raster lookups on RasterField (only implemented on
+ PostGIS) allowed remote attackers to inject SQL via the band index
+ parameter.
+
+ - CVE-2026-1285: The django.utils.text.Truncator.chars() and
+ Truncator.words() methods (with html=True) and the truncatechars_html and
+ truncatewords_html template filters allowed a remote attacker to cause a
+ potential denial-of-service via crafted inputs containing a large number
+ of unmatched HTML end tags.
+
+ - CVE-2026-1287: FilteredRelation was subject to SQL injection in column
+ aliases via control characters using a suitably crafted dictionary, with
+ dictionary expansion, as the **kwargs passed to QuerySet methods
+ annotate(), aggregate(), extra(), values(), values_list() and alias().
+
+ - CVE-2026-1312: QuerySet.order_by() was subject to SQL injection in column
+ aliases containing periods when the same alias is, using a suitably
+ crafted dictionary, with dictionary expansion, used in FilteredRelation.
+
+ <https://docs.djangoproject.com/en/dev/releases/4.2.28/> (Closes: #1126914)
+
+ -- Chris Lamb <[email protected]> Wed, 18 Feb 2026 14:44:14 -0800
+
python-django (3:4.2.27-0+deb13u1) trixie-security; urgency=high
* New upstream security release:
diff --git Django.egg-info/PKG-INFO Django.egg-info/PKG-INFO
index fe5c1526a..96b3c40fe 100644
--- Django.egg-info/PKG-INFO
+++ Django.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: Django
-Version: 4.2.27
+Version: 4.2.28
Summary: A high-level Python web framework that encourages rapid development
and clean, pragmatic design.
Author-email: Django Software Foundation <[email protected]>
License: BSD-3-Clause
diff --git Django.egg-info/SOURCES.txt Django.egg-info/SOURCES.txt
index c03bfd953..2e56cec11 100644
--- Django.egg-info/SOURCES.txt
+++ Django.egg-info/SOURCES.txt
@@ -4211,6 +4211,7 @@ docs/releases/4.2.24.txt
docs/releases/4.2.25.txt
docs/releases/4.2.26.txt
docs/releases/4.2.27.txt
+docs/releases/4.2.28.txt
docs/releases/4.2.3.txt
docs/releases/4.2.4.txt
docs/releases/4.2.5.txt
diff --git PKG-INFO PKG-INFO
index fe5c1526a..96b3c40fe 100644
--- PKG-INFO
+++ PKG-INFO
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: Django
-Version: 4.2.27
+Version: 4.2.28
Summary: A high-level Python web framework that encourages rapid development
and clean, pragmatic design.
Author-email: Django Software Foundation <[email protected]>
License: BSD-3-Clause
diff --git django/__init__.py django/__init__.py
index 06680666d..cc928db2c 100644
--- django/__init__.py
+++ django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (4, 2, 27, "final", 0)
+VERSION = (4, 2, 28, "final", 0)
__version__ = get_version(VERSION)
diff --git django/contrib/auth/handlers/modwsgi.py
django/contrib/auth/handlers/modwsgi.py
index 591ec72cb..086db89fc 100644
--- django/contrib/auth/handlers/modwsgi.py
+++ django/contrib/auth/handlers/modwsgi.py
@@ -4,24 +4,47 @@ from django.contrib import auth
UserModel = auth.get_user_model()
+def _get_user(username):
+ """
+ Return the UserModel instance for `username`.
+
+ If no matching user exists, or if the user is inactive, return None, in
+ which case the default password hasher is run to mitigate timing attacks.
+ """
+ try:
+ user = UserModel._default_manager.get_by_natural_key(username)
+ except UserModel.DoesNotExist:
+ user = None
+ else:
+ if not user.is_active:
+ user = None
+
+ if user is None:
+ # Run the default password hasher once to reduce the timing difference
+ # between existing/active and nonexistent/inactive users (#20760).
+ UserModel().set_password("")
+
+ return user
+
+
def check_password(environ, username, password):
"""
Authenticate against Django's auth database.
mod_wsgi docs specify None, True, False as return value depending
on whether the user exists and authenticates.
+
+ Return None if the user does not exist, return False if the user exists but
+ password is not correct, and return True otherwise.
+
"""
# db connection state is managed similarly to the wsgi handler
# as mod_wsgi may call these functions outside of a request/response cycle
db.reset_queries()
try:
- try:
- user = UserModel._default_manager.get_by_natural_key(username)
- except UserModel.DoesNotExist:
- return None
- if not user.is_active:
- return None
- return user.check_password(password)
+ user = _get_user(username)
+ if user:
+ return user.check_password(password)
finally:
db.close_old_connections()
diff --git django/contrib/gis/db/backends/postgis/operations.py
django/contrib/gis/db/backends/postgis/operations.py
index b68db377f..d18ddab52 100644
--- django/contrib/gis/db/backends/postgis/operations.py
+++ django/contrib/gis/db/backends/postgis/operations.py
@@ -51,6 +51,9 @@ class PostGISOperator(SpatialOperator):
# Look for band indices and inject them if provided.
if lookup.band_lhs is not None and lhs_is_raster:
+ if not isinstance(lookup.band_lhs, int):
+ name = lookup.band_lhs.__class__.__name__
+ raise TypeError(f"Band index must be an integer, but got
{name!r}.")
if not self.func:
raise ValueError(
"Band indices are not allowed for this operator, it works
on bbox "
@@ -62,6 +65,9 @@ class PostGISOperator(SpatialOperator):
)
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 django/core/handlers/asgi.py django/core/handlers/asgi.py
index f0125e732..d95182311 100644
--- django/core/handlers/asgi.py
+++ django/core/handlers/asgi.py
@@ -2,6 +2,7 @@ import logging
import sys
import tempfile
import traceback
+from collections import defaultdict
from asgiref.sync import ThreadSensitiveContext, sync_to_async
@@ -81,6 +82,7 @@ class ASGIRequest(HttpRequest):
self.META["SERVER_NAME"] = "unknown"
self.META["SERVER_PORT"] = "0"
# Headers go into META.
+ _headers = defaultdict(list)
for name, value in self.scope.get("headers", []):
name = name.decode("latin1")
if name == "content-length":
@@ -92,9 +94,8 @@ class ASGIRequest(HttpRequest):
# HTTP/2 say only ASCII chars are allowed in headers, but decode
# latin1 just in case.
value = value.decode("latin1")
- if corrected_name in self.META:
- value = self.META[corrected_name] + "," + value
- self.META[corrected_name] = value
+ _headers[corrected_name].append(value)
+ self.META.update({name: ",".join(value) for name, value in
_headers.items()})
# Pull out request encoding, if provided.
self._set_content_type_params(self.META)
# Directly assign the body file to be our stream.
diff --git django/db/models/sql/compiler.py django/db/models/sql/compiler.py
index 3b438a1de..279adc704 100644
--- django/db/models/sql/compiler.py
+++ django/db/models/sql/compiler.py
@@ -402,7 +402,7 @@ class SQLCompiler:
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 django/db/models/sql/query.py django/db/models/sql/query.py
index 3b8071eab..59b40ebfc 100644
--- django/db/models/sql/query.py
+++ django/db/models/sql/query.py
@@ -46,9 +46,11 @@ from django.utils.tree import Node
__all__ = ["Query", "RawQuery"]
-# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline
-# SQL comments are forbidden in column aliases.
-FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/")
+# Quotation marks ('"`[]), whitespace characters, control characters,
+# semicolons, hashes, or inline SQL comments are forbidden in column aliases.
+FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(
+ r"['`\"\]\[;\s\x00-\x1F\x7F-\x9F]|#|--|/\*|\*/"
+)
# Inspired from
#
https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
@@ -1124,7 +1126,7 @@ class Query(BaseExpression):
if FORBIDDEN_ALIAS_PATTERN.search(alias):
raise ValueError(
"Column aliases cannot contain whitespace characters, hashes, "
- "quotation marks, semicolons, or SQL comments."
+ "control characters, quotation marks, semicolons, or SQL
comments."
)
def add_annotation(self, annotation, alias, select=True):
@@ -1620,6 +1622,11 @@ class Query(BaseExpression):
return target_clause
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
lookups = dict(get_children_from_q(filtered_relation.condition))
diff --git django/utils/text.py django/utils/text.py
index b018f2601..694baf1ac 100644
--- django/utils/text.py
+++ django/utils/text.py
@@ -272,15 +272,11 @@ class Truncator(SimpleLazyObject):
if self_closing or tagname in html4_singlets:
pass
elif closing_tag:
- # Check for match in open tags list
- try:
- i = open_tags.index(tagname)
- except ValueError:
- pass
- else:
- # SGML: An end tag closes, back to the matching start tag,
- # all unclosed intervening start tags with omitted end tags
- open_tags = open_tags[i + 1 :]
+ # Remove from the list only if the tag matches the most
+ # recently opened tag (LIFO). This avoids O(n) linear scans
+ # for unmatched end tags if `list.index()` would be called.
+ if open_tags and open_tags[0] == tagname:
+ open_tags = open_tags[1:]
else:
# Add it to the start of the open tags list
open_tags.insert(0, tagname)
diff --git docs/releases/4.2.28.txt docs/releases/4.2.28.txt
new file mode 100644
index 000000000..a618474f0
--- /dev/null
+++ docs/releases/4.2.28.txt
@@ -0,0 +1,82 @@
+===========================
+Django 4.2.28 release notes
+===========================
+
+*February 3, 2026*
+
+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</howto/deployment/wsgi/apache-auth>`
+allowed remote attackers to enumerate users via a timing attack.
+
+This issue has severity "low" according to the :ref:`Django security policy
+<security-disclosure>`.
+
+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 <security-disclosure>`.
+
+CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS
+====================================================================
+
+:ref:`Raster lookups <spatial-lookup-raster>` 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
+<security-disclosure>`.
+Django 4.2.28 fixes two security issues with severity "moderate", three
+security issues with severity "moderate", and one security issue with severity
+"low" in 4.2.27.
+
+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 Django security policy.
+This issue has severity "moderate" according to the :ref:`Django security
+policy <security-disclosure>`.
+
+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
+<security-disclosure>`.
+
+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
+<security-disclosure>`.
diff --git docs/releases/index.txt docs/releases/index.txt
index f5a6770bc..e71fc6f52 100644
--- docs/releases/index.txt
+++ docs/releases/index.txt
@@ -26,6 +26,7 @@ versions of the documentation contain the release notes for
any later releases.
.. toctree::
:maxdepth: 1
+ 4.2.28
4.2.27
4.2.26
4.2.25
diff --git docs/releases/security.txt docs/releases/security.txt
index e5b9878ab..b7bd09f17 100644
--- docs/releases/security.txt
+++ docs/releases/security.txt
@@ -36,6 +36,30 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+December 2, 2025 - :cve:`2025-13372`
+------------------------------------
+
+Potential SQL injection in ``FilteredRelation`` column aliases on PostgreSQL.
+`Full description
+<https://www.djangoproject.com/weblog/2025/dec/02/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <56aea00c3c5e1aacf4ed05f8ee06c2e78f02cea0>`
+* Django 5.2 :commit:`(patch) <479415ce5249bcdebeb6570c72df2a87f45a7bbf>`
+* Django 5.1 :commit:`(patch) <9c6a5bde24240382807d13bc3748d08444709355>`
+* Django 4.2 :commit:`(patch) <f997037b235f6b5c9e7c4a501491ec45f3400f3d>`
+
+December 2, 2025 - :cve:`2025-64460`
+------------------------------------
+
+Potential denial-of-service vulnerability in XML serializer text extraction.
+`Full description
+<https://www.djangoproject.com/weblog/2025/dec/02/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <1dbd07a608e495a0c229edaaf84d58d8976313b5>`
+* Django 5.2 :commit:`(patch) <99e7d22f55497278d0bcb2e15e72ef532e62a31d>`
+* Django 5.1 :commit:`(patch) <0db9ea4669312f1f4973e09f4bca06ab9c1ec74b>`
+* Django 4.2 :commit:`(patch) <4d2b8803bebcdefd2b76e9e8fc528d5fddea93f0>`
+
November 5, 2025 - :cve:`2025-64458`
------------------------------------
diff --git tests/aggregation/tests.py tests/aggregation/tests.py
index 277c0507f..abd43fc21 100644
--- tests/aggregation/tests.py
+++ tests/aggregation/tests.py
@@ -2,6 +2,7 @@ import datetime
import math
import re
from decimal import Decimal
+from itertools import chain
from django.core.exceptions import FieldError
from django.db import connection
@@ -2088,13 +2089,18 @@ class AggregateTestCase(TestCase):
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 tests/annotations/tests.py tests/annotations/tests.py
index d876e3a6f..935691798 100644
--- tests/annotations/tests.py
+++ tests/annotations/tests.py
@@ -1,5 +1,6 @@
import datetime
from decimal import Decimal
+from itertools import chain
from django.core.exceptions import FieldDoesNotExist, FieldError
from django.db import connection
@@ -1115,22 +1116,32 @@ class NonAggregateAnnotationTestCase(TestCase):
)
def test_alias_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.annotate(**{crafted_alias: Value(1)})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.annotate(**{crafted_alias: Value(1)})
def test_alias_filtered_relation_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.annotate(**{crafted_alias:
FilteredRelation("author")})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.annotate(**{crafted_alias:
FilteredRelation("author")})
def test_alias_forbidden_chars(self):
tests = [
@@ -1148,10 +1159,11 @@ class NonAggregateAnnotationTestCase(TestCase):
"alias[",
"alias]",
"ali#as",
+ "ali\0as",
]
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
)
for crafted_alias in tests:
with self.subTest(crafted_alias):
@@ -1428,22 +1440,32 @@ class AliasTests(TestCase):
getattr(qs, operation)("rating_alias")
def test_alias_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.alias(**{crafted_alias: Value(1)})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.alias(**{crafted_alias: Value(1)})
def test_alias_filtered_relation_sql_injection(self):
- crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Book.objects.alias(**{crafted_alias: FilteredRelation("authors")})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "annotations_book"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.alias(**{crafted_alias:
FilteredRelation("authors")})
def test_alias_filtered_relation_sql_injection_dollar_sign(self):
qs = Book.objects.alias(
diff --git tests/asgi/tests.py tests/asgi/tests.py
index f2e293d8b..9395c8626 100644
--- tests/asgi/tests.py
+++ tests/asgi/tests.py
@@ -7,6 +7,7 @@ from asgiref.testing import ApplicationCommunicator
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
from django.core.asgi import get_asgi_application
+from django.core.handlers.asgi import ASGIRequest
from django.core.signals import request_finished, request_started
from django.db import close_old_connections
from django.test import (
@@ -193,6 +194,32 @@ class ASGITest(SimpleTestCase):
self.assertEqual(response_body["type"], "http.response.body")
self.assertEqual(response_body["body"], b"Echo!")
+ 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_untouched_request_body_gets_closed(self):
application = get_asgi_application()
scope = self.async_request_factory._base_scope(method="POST",
path="/post/")
diff --git tests/auth_tests/test_handlers.py tests/auth_tests/test_handlers.py
index a6b53a9ef..857c53257 100644
--- tests/auth_tests/test_handlers.py
+++ 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,26 @@ class ModWsgiHandlerTestCase(TransactionTestCase):
self.assertEqual(groups_for_user({}, "test"), [b"test_group"])
self.assertEqual(groups_for_user({}, "test1"), [])
+
+ def test_check_password_fake_runtime(self):
+ """
+ Hasher is run once regardless of whether the user exists. Refs #20760.
+ """
+ User.objects.create_user("test", "[email protected]", "test")
+ User.objects.create_user("inactive", "[email protected]", "test",
is_active=False)
+ User.objects.create_user("unusable", "[email protected]")
+
+ hasher = get_hasher()
+
+ for username, password in [
+ ("test", "test"),
+ ("test", "wrong"),
+ ("inactive", "test"),
+ ("inactive", "wrong"),
+ ("unusable", "test"),
+ ("doesnotexist", "test"),
+ ]:
+ with self.subTest(username=username, password=password):
+ with mock.patch.object(hasher, "encode") as mock_make_password:
+ check_password({}, username, password)
+ mock_make_password.assert_called_once()
diff --git tests/expressions/test_queryset_values.py
tests/expressions/test_queryset_values.py
index 080ee0618..afd8a5115 100644
--- tests/expressions/test_queryset_values.py
+++ tests/expressions/test_queryset_values.py
@@ -1,3 +1,5 @@
+from itertools import chain
+
from django.db.models import F, Sum
from django.test import TestCase, skipUnlessDBFeature
@@ -35,26 +37,36 @@ class ValuesExpressionsTests(TestCase):
)
def test_values_expression_alias_sql_injection(self):
- crafted_alias = """injected_name" from "expressions_company"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
)
- with self.assertRaisesMessage(ValueError, msg):
- Company.objects.values(**{crafted_alias: F("ceo__salary")})
+ for crafted_alias in [
+ """injected_name" from "expressions_company"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Company.objects.values(**{crafted_alias: F("ceo__salary")})
@skipUnlessDBFeature("supports_json_field")
def test_values_expression_alias_sql_injection_json_field(self):
- crafted_alias = """injected_name" from "expressions_company"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
)
- with self.assertRaisesMessage(ValueError, msg):
- JSONFieldModel.objects.values(f"data__{crafted_alias}")
+ for crafted_alias in [
+ """injected_name" from "expressions_company"; --""",
+ # Control characters.
+ *(chr(c) for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ JSONFieldModel.objects.values(f"data__{crafted_alias}")
- with self.assertRaisesMessage(ValueError, msg):
- JSONFieldModel.objects.values_list(f"data__{crafted_alias}")
+ with self.assertRaisesMessage(ValueError, msg):
+
JSONFieldModel.objects.values_list(f"data__{crafted_alias}")
def test_values_expression_group_by(self):
# values() applies annotate() first, so values selected are grouped by
diff --git tests/filtered_relation/tests.py tests/filtered_relation/tests.py
index 0fce8b092..4847d1f04 100644
--- tests/filtered_relation/tests.py
+++ tests/filtered_relation/tests.py
@@ -211,6 +211,19 @@ class FilteredRelationTests(TestCase):
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 tests/gis_tests/rasterapp/test_rasterfield.py
tests/gis_tests/rasterapp/test_rasterfield.py
index 3f2ce770a..89c4ec485 100644
--- tests/gis_tests/rasterapp/test_rasterfield.py
+++ tests/gis_tests/rasterapp/test_rasterfield.py
@@ -2,7 +2,11 @@ import json
from django.contrib.gis.db.models.fields import BaseSpatialField
from django.contrib.gis.db.models.functions import Distance
-from django.contrib.gis.db.models.lookups import DistanceLookupBase, GISLookup
+from django.contrib.gis.db.models.lookups import (
+ DistanceLookupBase,
+ GISLookup,
+ RasterBandTransform,
+)
from django.contrib.gis.gdal import GDALRaster
from django.contrib.gis.geos import GEOSGeometry
from django.contrib.gis.measure import D
@@ -356,6 +360,47 @@ class RasterFieldTest(TransactionTestCase):
with self.assertRaisesMessage(ValueError, msg):
qs.count()
+ def test_lookup_invalid_band_rhs(self):
+ rast = GDALRaster(json.loads(JSON_RASTER))
+ qs = RasterModel.objects.filter(rast__contains=(rast, "evil"))
+ msg = "Band index must be an integer, but got 'str'."
+ with self.assertRaisesMessage(TypeError, msg):
+ qs.count()
+
+ def test_lookup_invalid_band_lhs(self):
+ """
+ Typical left-hand side usage is protected against non-integers, but for
+ defense-in-depth purposes, construct custom lookups that evade the
+ `int()` and `+ 1` checks in the lookups shipped by django.contrib.gis.
+ """
+
+ # Evade the int() call in RasterField.get_transform().
+ class MyRasterBandTransform(RasterBandTransform):
+ band_index = "evil"
+
+ def process_band_indices(self, *args, **kwargs):
+ self.band_lhs = self.lhs.band_index
+ self.band_rhs, *self.rhs_params = self.rhs_params
+
+ # Evade the `+ 1` call in BaseSpatialField.process_band_indices().
+ ContainsLookup =
RasterModel._meta.get_field("rast").get_lookup("contains")
+
+ class MyContainsLookup(ContainsLookup):
+ def process_band_indices(self, *args, **kwargs):
+ self.band_lhs = self.lhs.band_index
+ self.band_rhs, *self.rhs_params = self.rhs_params
+
+ RasterField = RasterModel._meta.get_field("rast")
+ RasterField.register_lookup(MyContainsLookup, "contains")
+ self.addCleanup(RasterField.register_lookup, ContainsLookup,
"contains")
+
+ qs = RasterModel.objects.annotate(
+ transformed=MyRasterBandTransform("rast")
+ ).filter(transformed__contains=(F("transformed"), 1))
+ msg = "Band index must be an integer, but got 'str'."
+ with self.assertRaisesMessage(TypeError, msg):
+ list(qs)
+
def test_isvalid_lookup_with_raster_error(self):
qs = RasterModel.objects.filter(rast__isvalid=True)
msg = (
diff --git tests/ordering/tests.py tests/ordering/tests.py
index b29404ed7..30da02587 100644
--- tests/ordering/tests.py
+++ tests/ordering/tests.py
@@ -7,6 +7,7 @@ from django.db.models import (
Count,
DateTimeField,
F,
+ FilteredRelation,
Max,
OrderBy,
OuterRef,
@@ -392,6 +393,35 @@ class OrderingTests(TestCase):
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 tests/queries/tests.py tests/queries/tests.py
index 2290ea29b..d0af410eb 100644
--- tests/queries/tests.py
+++ tests/queries/tests.py
@@ -2,6 +2,7 @@ import datetime
import pickle
import sys
import unittest
+from itertools import chain
from operator import attrgetter
from threading import Lock
@@ -1941,13 +1942,18 @@ class Queries5Tests(TestCase):
)
def test_extra_select_alias_sql_injection(self):
- crafted_alias = """injected_name" from "queries_note"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, hashes,
quotation "
- "marks, semicolons, or SQL comments."
- )
- with self.assertRaisesMessage(ValueError, msg):
- Note.objects.extra(select={crafted_alias: "1"})
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "control characters, quotation marks, semicolons, or SQL comments."
+ )
+ for crafted_alias in [
+ """injected_name" from "queries_note"; --""",
+ # Control characters.
+ *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))),
+ ]:
+ with self.subTest(crafted_alias):
+ with self.assertRaisesMessage(ValueError, msg):
+ Note.objects.extra(select={crafted_alias: "1"})
def test_queryset_reuse(self):
# Using querysets doesn't mutate aliases.
diff --git tests/runtests.py tests/runtests.py
index b67898839..b3150ab69 100755
--- tests/runtests.py
+++ tests/runtests.py
@@ -65,16 +65,6 @@ RUNTESTS_DIR = os.path.abspath(os.path.dirname(__file__))
TEMPLATE_DIR = os.path.join(RUNTESTS_DIR, "templates")
-# Create a specific subdirectory for the duration of the test suite.
-TMPDIR = tempfile.mkdtemp(prefix="django_")
-# Set the TMPDIR environment variable in addition to tempfile.tempdir
-# so that children processes inherit it.
-tempfile.tempdir = os.environ["TMPDIR"] = TMPDIR
-
-# Removing the temporary TMPDIR.
-atexit.register(shutil.rmtree, TMPDIR)
-
-
# This is a dict mapping RUNTESTS_DIR subdirectory to subdirectories of that
# directory to skip when searching for test modules.
SUBDIRS_TO_SKIP = {
@@ -197,6 +187,7 @@ def get_filtered_test_modules(start_at, start_after,
gis_enabled, test_labels=No
def setup_collect_tests(start_at, start_after, test_labels=None):
+ TMPDIR = os.environ["TMPDIR"]
state = {
"INSTALLED_APPS": settings.INSTALLED_APPS,
"ROOT_URLCONF": getattr(settings, "ROOT_URLCONF", ""),
@@ -334,13 +325,6 @@ def setup_run_tests(verbosity, start_at, start_after,
test_labels=None):
def teardown_run_tests(state):
teardown_collect_tests(state)
- # Discard the multiprocessing.util finalizer that tries to remove a
- # temporary directory that's already removed by this script's
- # atexit.register(shutil.rmtree, TMPDIR) handler. Prevents
- # FileNotFoundError at the end of a test run (#27890).
- from multiprocessing.util import _finalizer_registry
-
- _finalizer_registry.pop((-100, 0), None)
del os.environ["RUNNING_DJANGOS_TEST_SUITE"]
@@ -539,6 +523,14 @@ def paired_tests(paired_test, options, test_labels,
start_at, start_after):
if __name__ == "__main__":
+ # Create a specific subdirectory for the duration of the test suite.
+ TMPDIR = tempfile.mkdtemp(prefix="django_")
+ # Set the TMPDIR environment variable in addition to tempfile.tempdir
+ # so that children processes inherit it.
+ tempfile.tempdir = os.environ["TMPDIR"] = TMPDIR
+ # Remove the temporary TMPDIR.
+ atexit.register(shutil.rmtree, TMPDIR)
+
parser = argparse.ArgumentParser(description="Run the Django test suite.")
parser.add_argument(
"modules",
diff --git tests/utils_tests/test_html.py tests/utils_tests/test_html.py
index f755b8ceb..a5acc582f 100644
--- tests/utils_tests/test_html.py
+++ tests/utils_tests/test_html.py
@@ -1,3 +1,4 @@
+import math
import os
import sys
from datetime import datetime
@@ -92,17 +93,35 @@ class TestUtilsHtml(SimpleTestCase):
# old and new results. The check below is temporary until all supported
# Python versions and CI workers include the fix. See:
# https://github.com/python/cpython/commit/6eb6c5db
- min_fixed = {
+ min_fixed_security = {
(3, 14): (3, 14),
(3, 13): (3, 13, 6),
(3, 12): (3, 12, 12),
(3, 11): (3, 11, 14),
(3, 10): (3, 10, 19),
(3, 9): (3, 9, 24),
+ # Not fixed in 3.8.
+ (3, 8): (3, 8, math.inf),
}
- py_version = sys.version_info[:2]
- htmlparser_fixed = (
- py_version in min_fixed and sys.version_info >=
min_fixed[py_version]
+ # Similarly, there was a fix for terminating incomplete entities. See:
+ # https://github.com/python/cpython/commit/95296a9d
+ min_fixed_incomplete_entities = {
+ (3, 14): (3, 14, 1),
+ (3, 13): (3, 13, 10),
+ # Not fixed in the following versions.
+ (3, 12): (3, 12, math.inf),
+ (3, 11): (3, 11, math.inf),
+ (3, 10): (3, 10, math.inf),
+ (3, 9): (3, 9, math.inf),
+ (3, 8): (3, 8, math.inf),
+ }
+ major_version = sys.version_info[:2]
+ htmlparser_fixed_security = sys.version_info >= min_fixed_security.get(
+ major_version, major_version
+ )
+ htmlparser_fixed_incomplete_entities = (
+ sys.version_info
+ >= min_fixed_incomplete_entities.get(major_version, major_version)
)
items = (
(
@@ -130,16 +149,19 @@ class TestUtilsHtml(SimpleTestCase):
# https://bugs.python.org/issue20288
("&gotcha&#;<>", "&gotcha&#;<>"),
("<sc<!-- -->ript>test<<!-- -->/script>", "ript>test"),
- ("<script>alert()</script>&h", "alert()h"),
+ (
+ "<script>alert()</script>&h",
+ "alert()&h;" if htmlparser_fixed_incomplete_entities else
"alert()h",
+ ),
(
"><!" + ("&" * 16000) + "D",
- ">" if htmlparser_fixed else "><!" + ("&" * 16000) + "D",
+ ">" if htmlparser_fixed_security else "><!" + ("&" * 16000) +
"D",
),
("X<<<<br>br>br>br>X", "XX"),
("<" * 50 + "a>" * 50, ""),
(
">" + "<a" * 500 + "a",
- ">" if htmlparser_fixed else ">" + "<a" * 500 + "a",
+ ">" if htmlparser_fixed_security else ">" + "<a" * 500 + "a",
),
("<a" * 49 + "a" * 951, "<a" * 49 + "a" * 951),
("<" + "a" * 1_002, "<" + "a" * 1_002),
diff --git tests/utils_tests/test_text.py tests/utils_tests/test_text.py
index d1890e7b6..5a018d732 100644
--- tests/utils_tests/test_text.py
+++ tests/utils_tests/test_text.py
@@ -95,6 +95,16 @@ class TestUtilsText(SimpleTestCase):
text.Truncator(lazystr("The quick brown fox")).chars(10), "The
quick\u2026"
)
+ def test_truncate_chars_html_with_misnested_tags(self):
+ # LIFO removal keeps all tags when a middle tag is closed out of order.
+ # With <a><b><c></b>, the </b> doesn't match <c>, so all tags remain
+ # in the stack and are properly closed at truncation.
+ truncator = text.Truncator("<a><b><c></b>XXXX")
+ self.assertEqual(
+ truncator.chars(2, html=True, truncate=""),
+ "<a><b><c></b>XX</c></b></a>",
+ )
+
@patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000)
def test_truncate_chars_html_size_limit(self):
max_len = text.Truncator.MAX_LENGTH_HTML