--- Begin Message ---
Package: release.debian.org
Severity: normal
Tags: trixie
User: [email protected]
Usertags: pu
Dear stable release managers,
Re. https://salsa.debian.org/lts-team/lts-updates-tasks/-/issues/300,
please consider python-django (3:4.2.27-1+deb13u1) for trixie:
python-django (3:4.2.27-1+deb13u1) trixie; urgency=high
.
* New upstream security release:
.
- CVE-2025-13372: Fix a potential SQL injection attack in FilteredRelation
column aliases when using PostgreSQL. FilteredRelation was subject to
SQL
injection in column aliases via a suitably crafted dictionary as the
**kwargs passed to QuerySet.annotate() or QuerySet.alias().
.
- CVE-2025-57833: Potential SQL injection in FilteredRelation column
aliases. The FilteredRelation feature in Django was subject to a
potential SQL injection vulnerability in column aliases that was
exploitable via suitably crafted dictionary with dictionary expansion as
the **kwargs passed QuerySet.annotate() or QuerySet.alias(). This CVE
was fixed in Django 4.2.24. (Closes: #1113865)
.
- CVE-2025-59681: Potential SQL injection in QuerySet.annotate(), alias(),
aggregate() and extra() on MySQL and MariaDB. QuerySet.annotate(),
QuerySet.alias(), QuerySet.aggregate() and QuerySet.extra() methods were
subject to SQL injection in column aliases, using a suitably crafted
dictionary with dictionary expansion as the **kwargs passed to these
methods on MySQL and MariaDB. This CVE was fixed in Django 4.2.25.
.
- CVE-2025-59682: Potential partial directory-traversal via
archive.extract(). The django.utils.archive.extract() function, used by
startapp --template and startproject --template allowed partial
directory-traversal via an archive with file paths sharing a common
prefix with the target directory. This CVE was fixed in Django 4.2.25.
.
- CVE-2025-64459: Prevent a potential SQL injection via _connector keyword
argument in QuerySet/Q objects. The methods QuerySet.filter(),
QuerySet.exclude(), and QuerySet.get() and the class Q() were subject to
SQL injection when using a suitably crafted dictionary (with dictionary
expansion) as the _connector argument. This CVE was fixed in Django
4.2.26.
.
- CVE-2025-64460: Prevent a potential denial-of-service vulnerability in
XML serializer text extraction. An algorithmic complexity issue in
django.core.serializers.xml_serializer.getInnerText() allowed a remote
attacker to cause a potential denial-of-service triggering CPU and
memory
exhaustion via a specially crafted XML input submitted to a service that
invokes XML Deserializer. The vulnerability resulted from repeated
string
concatenation while recursively collecting text nodes, which produced
superlinear computation. (Closes: #1121788)
.
<https://docs.djangoproject.com/en/4.2/releases/4.2.27/>
The relevant Debusine job is as follows:
https://debusine.debian.net/debian/developers/work-request/357875/
There are three failures of autopkgtests in reverse-dependencies, which I
have investigated as follows:
* src:debusine — appears to be some unshare(1)/namespace issue.
* src:django-ldapdb — "Depends: python3-volatildap but it is not installable"
* src:pyinstaller — Socket/internet issue
"tests/functional/test_scipy.py::test_scipy[onedir-scipy.spatial] Error:
websocket: close 1006 (abnormal closure): unexpected EOF"
The full diff is attached.
Regards,
--
,''`.
: :' : Chris Lamb
`. `'` [email protected] / chris-lamb.co.uk
`-
diff --git debian/changelog debian/changelog
index 8120d436f..a4c0b0d99 100644
--- debian/changelog
+++ debian/changelog
@@ -1,3 +1,52 @@
+python-django (3:4.2.27-1+deb13u1) trixie; urgency=high
+
+ * New upstream security release:
+
+ - CVE-2025-13372: Fix a potential SQL injection attack in FilteredRelation
+ column aliases when using PostgreSQL. FilteredRelation was subject to SQL
+ injection in column aliases via a suitably crafted dictionary as the
+ **kwargs passed to QuerySet.annotate() or QuerySet.alias().
+
+ - CVE-2025-57833: Potential SQL injection in FilteredRelation column
+ aliases. The FilteredRelation feature in Django was subject to a
+ potential SQL injection vulnerability in column aliases that was
+ exploitable via suitably crafted dictionary with dictionary expansion as
+ the **kwargs passed QuerySet.annotate() or QuerySet.alias(). This CVE
+ was fixed in Django 4.2.24. (Closes: #1113865)
+
+ - CVE-2025-59681: Potential SQL injection in QuerySet.annotate(), alias(),
+ aggregate() and extra() on MySQL and MariaDB. QuerySet.annotate(),
+ QuerySet.alias(), QuerySet.aggregate() and QuerySet.extra() methods were
+ subject to SQL injection in column aliases, using a suitably crafted
+ dictionary with dictionary expansion as the **kwargs passed to these
+ methods on MySQL and MariaDB. This CVE was fixed in Django 4.2.25.
+
+ - CVE-2025-59682: Potential partial directory-traversal via
+ archive.extract(). The django.utils.archive.extract() function, used by
+ startapp --template and startproject --template allowed partial
+ directory-traversal via an archive with file paths sharing a common
+ prefix with the target directory. This CVE was fixed in Django 4.2.25.
+
+ - CVE-2025-64459: Prevent a potential SQL injection via _connector keyword
+ argument in QuerySet/Q objects. The methods QuerySet.filter(),
+ QuerySet.exclude(), and QuerySet.get() and the class Q() were subject to
+ SQL injection when using a suitably crafted dictionary (with dictionary
+ expansion) as the _connector argument. This CVE was fixed in Django
+ 4.2.26.
+
+ - CVE-2025-64460: Prevent a potential denial-of-service vulnerability in
+ XML serializer text extraction. An algorithmic complexity issue in
+ django.core.serializers.xml_serializer.getInnerText() allowed a remote
+ attacker to cause a potential denial-of-service triggering CPU and memory
+ exhaustion via a specially crafted XML input submitted to a service that
+ invokes XML Deserializer. The vulnerability resulted from repeated string
+ concatenation while recursively collecting text nodes, which produced
+ superlinear computation. (Closes: #1121788)
+
+ <https://docs.djangoproject.com/en/4.2/releases/4.2.27/>
+
+ -- Chris Lamb <[email protected]> Fri, 23 Jan 2026 10:43:29 -0800
+
python-django (3:4.2.23-1) unstable; urgency=high
* New upstream bugfix release. Quoting upstream:
diff --git Django.egg-info/PKG-INFO Django.egg-info/PKG-INFO
index 5c98d60ee..fe5c1526a 100644
--- Django.egg-info/PKG-INFO
+++ Django.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: Django
-Version: 4.2.23
+Version: 4.2.27
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 ca8895c96..c03bfd953 100644
--- Django.egg-info/SOURCES.txt
+++ Django.egg-info/SOURCES.txt
@@ -3315,6 +3315,7 @@ django/db/backends/oracle/validation.py
django/db/backends/postgresql/__init__.py
django/db/backends/postgresql/base.py
django/db/backends/postgresql/client.py
+django/db/backends/postgresql/compiler.py
django/db/backends/postgresql/creation.py
django/db/backends/postgresql/features.py
django/db/backends/postgresql/introspection.py
@@ -4206,6 +4207,10 @@ docs/releases/4.2.20.txt
docs/releases/4.2.21.txt
docs/releases/4.2.22.txt
docs/releases/4.2.23.txt
+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.3.txt
docs/releases/4.2.4.txt
docs/releases/4.2.5.txt
@@ -4299,7 +4304,6 @@ js_tests/admin/inlines.test.js
js_tests/admin/jsi18n-mocks.test.js
js_tests/admin/navigation.test.js
js_tests/gis/mapwidget.test.js
-scripts/manage_translations.py
tests/.coveragerc
tests/README.rst
tests/runtests.py
diff --git MANIFEST.in MANIFEST.in
index cba764b41..0492e7cbc 100644
--- MANIFEST.in
+++ MANIFEST.in
@@ -10,6 +10,6 @@ graft django
graft docs
graft extras
graft js_tests
-graft scripts
graft tests
global-exclude *.py[co]
+prune scripts
diff --git PKG-INFO PKG-INFO
index 5c98d60ee..fe5c1526a 100644
--- PKG-INFO
+++ PKG-INFO
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: Django
-Version: 4.2.23
+Version: 4.2.27
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 49e1e4201..06680666d 100644
--- django/__init__.py
+++ django/__init__.py
@@ -1,6 +1,6 @@
from django.utils.version import get_version
-VERSION = (4, 2, 23, "final", 0)
+VERSION = (4, 2, 27, "final", 0)
__version__ = get_version(VERSION)
diff --git django/core/serializers/xml_serializer.py
django/core/serializers/xml_serializer.py
index 3f9955aa2..5db8c067c 100644
--- django/core/serializers/xml_serializer.py
+++ django/core/serializers/xml_serializer.py
@@ -2,7 +2,8 @@
XML serializer.
"""
import json
-from xml.dom import pulldom
+from contextlib import contextmanager
+from xml.dom import minidom, pulldom
from xml.sax import handler
from xml.sax.expatreader import ExpatParser as _ExpatParser
@@ -14,6 +15,25 @@ from django.db import DEFAULT_DB_ALIAS, models
from django.utils.xmlutils import SimplerXMLGenerator,
UnserializableContentError
+@contextmanager
+def fast_cache_clearing():
+ """Workaround for performance issues in minidom document checks.
+
+ Speeds up repeated DOM operations by skipping unnecessary full traversal
+ of the DOM tree.
+ """
+ module_helper_was_lambda = False
+ if original_fn := getattr(minidom, "_in_document", None):
+ module_helper_was_lambda = original_fn.__name__ == "<lambda>"
+ if not module_helper_was_lambda:
+ minidom._in_document = lambda node: bool(node.ownerDocument)
+ try:
+ yield
+ finally:
+ if original_fn and not module_helper_was_lambda:
+ minidom._in_document = original_fn
+
+
class Serializer(base.Serializer):
"""Serialize a QuerySet to XML."""
@@ -208,7 +228,8 @@ class Deserializer(base.Deserializer):
def __next__(self):
for event, node in self.event_stream:
if event == "START_ELEMENT" and node.nodeName == "object":
- self.event_stream.expandNode(node)
+ with fast_cache_clearing():
+ self.event_stream.expandNode(node)
return self._handle_object(node)
raise StopIteration
@@ -392,19 +413,25 @@ class Deserializer(base.Deserializer):
def getInnerText(node):
"""Get all the inner text of a DOM node (recursively)."""
+ inner_text_list = getInnerTextList(node)
+ return "".join(inner_text_list)
+
+
+def getInnerTextList(node):
+ """Return a list of the inner texts of a DOM node (recursively)."""
# inspired by
https://mail.python.org/pipermail/xml-sig/2005-March/011022.html
- inner_text = []
+ result = []
for child in node.childNodes:
if (
child.nodeType == child.TEXT_NODE
or child.nodeType == child.CDATA_SECTION_NODE
):
- inner_text.append(child.data)
+ result.append(child.data)
elif child.nodeType == child.ELEMENT_NODE:
- inner_text.extend(getInnerText(child))
+ result.extend(getInnerTextList(child))
else:
pass
- return "".join(inner_text)
+ return result
# Below code based on Christian Heimes' defusedxml
diff --git django/db/backends/postgresql/compiler.py
django/db/backends/postgresql/compiler.py
new file mode 100644
index 000000000..d4140c7f9
--- /dev/null
+++ django/db/backends/postgresql/compiler.py
@@ -0,0 +1,24 @@
+from django.db.models.sql.compiler import ( # isort:skip
+ SQLAggregateCompiler,
+ SQLCompiler as BaseSQLCompiler,
+ SQLDeleteCompiler,
+ SQLInsertCompiler,
+ SQLUpdateCompiler,
+)
+
+__all__ = [
+ "SQLAggregateCompiler",
+ "SQLCompiler",
+ "SQLDeleteCompiler",
+ "SQLInsertCompiler",
+ "SQLUpdateCompiler",
+]
+
+
+class SQLCompiler(BaseSQLCompiler):
+ def quote_name_unless_alias(self, name):
+ if "$" in name:
+ raise ValueError(
+ "Dollar signs are not permitted in column aliases on
PostgreSQL."
+ )
+ return super().quote_name_unless_alias(name)
diff --git django/db/backends/postgresql/operations.py
django/db/backends/postgresql/operations.py
index c4d90b56a..24ad90e5f 100644
--- django/db/backends/postgresql/operations.py
+++ django/db/backends/postgresql/operations.py
@@ -23,6 +23,7 @@ def get_json_dumps(encoder):
class DatabaseOperations(BaseDatabaseOperations):
+ compiler_module = "django.db.backends.postgresql.compiler"
cast_char_field_without_max_length = "varchar"
explain_prefix = "EXPLAIN"
explain_options = frozenset(
diff --git django/db/models/query.py django/db/models/query.py
index 19c9ced23..31718a3a3 100644
--- django/db/models/query.py
+++ django/db/models/query.py
@@ -42,6 +42,8 @@ MAX_GET_RESULTS = 21
# The maximum number of items to display in a QuerySet.__repr__
REPR_OUTPUT_SIZE = 20
+PROHIBITED_FILTER_KWARGS = frozenset(["_connector", "_negated"])
+
class BaseIterable:
def __init__(
@@ -1455,6 +1457,9 @@ class QuerySet(AltersData):
return clone
def _filter_or_exclude_inplace(self, negate, args, kwargs):
+ if invalid_kwargs := PROHIBITED_FILTER_KWARGS.intersection(kwargs):
+ invalid_kwargs_str = ", ".join(f"'{k}'" for k in
sorted(invalid_kwargs))
+ raise TypeError(f"The following kwargs are invalid:
{invalid_kwargs_str}")
if negate:
self._query.add_q(~Q(*args, **kwargs))
else:
diff --git django/db/models/query_utils.py django/db/models/query_utils.py
index 5c5644cfb..a85a682e5 100644
--- django/db/models/query_utils.py
+++ django/db/models/query_utils.py
@@ -44,8 +44,12 @@ class Q(tree.Node):
XOR = "XOR"
default = AND
conditional = True
+ connectors = (None, AND, OR, XOR)
def __init__(self, *args, _connector=None, _negated=False, **kwargs):
+ if _connector not in self.connectors:
+ connector_reprs = ", ".join(f"{conn!r}" for conn in
self.connectors[1:])
+ raise ValueError(f"_connector must be one of {connector_reprs}, or
None.")
super().__init__(
children=[*args, *sorted(kwargs.items())],
connector=_connector,
diff --git django/db/models/sql/query.py django/db/models/sql/query.py
index e68fd9efb..3b8071eab 100644
--- django/db/models/sql/query.py
+++ django/db/models/sql/query.py
@@ -46,9 +46,9 @@ from django.utils.tree import Node
__all__ = ["Query", "RawQuery"]
-# Quotation marks ('"`[]), whitespace characters, semicolons, or inline
+# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline
# SQL comments are forbidden in column aliases.
-FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|--|/\*|\*/")
+FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/")
# Inspired from
#
https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
@@ -1123,8 +1123,8 @@ class Query(BaseExpression):
def check_alias(self, alias):
if FORBIDDEN_ALIAS_PATTERN.search(alias):
raise ValueError(
- "Column aliases cannot contain whitespace characters,
quotation marks, "
- "semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes, "
+ "quotation marks, semicolons, or SQL comments."
)
def add_annotation(self, annotation, alias, select=True):
@@ -1620,6 +1620,7 @@ class Query(BaseExpression):
return target_clause
def add_filtered_relation(self, filtered_relation, alias):
+ self.check_alias(alias)
filtered_relation.alias = alias
lookups = dict(get_children_from_q(filtered_relation.condition))
relation_lookup_parts, relation_field_parts, _ =
self.solve_lookup_type(
diff --git django/http/response.py django/http/response.py
index ea3114170..28b8fa6d3 100644
--- django/http/response.py
+++ django/http/response.py
@@ -21,7 +21,11 @@ from django.http.cookie import SimpleCookie
from django.utils import timezone
from django.utils.datastructures import CaseInsensitiveMapping
from django.utils.encoding import iri_to_uri
-from django.utils.http import content_disposition_header, http_date
+from django.utils.http import (
+ MAX_URL_REDIRECT_LENGTH,
+ content_disposition_header,
+ http_date,
+)
from django.utils.regex_helper import _lazy_re_compile
_charset_from_content_type_re = _lazy_re_compile(
@@ -614,7 +618,12 @@ class HttpResponseRedirectBase(HttpResponse):
def __init__(self, redirect_to, *args, **kwargs):
super().__init__(*args, **kwargs)
self["Location"] = iri_to_uri(redirect_to)
- parsed = urlparse(str(redirect_to))
+ redirect_to_str = str(redirect_to)
+ if len(redirect_to_str) > MAX_URL_REDIRECT_LENGTH:
+ raise DisallowedRedirect(
+ f"Unsafe redirect exceeding {MAX_URL_REDIRECT_LENGTH}
characters"
+ )
+ parsed = urlparse(redirect_to_str)
if parsed.scheme and parsed.scheme not in self.allowed_schemes:
raise DisallowedRedirect(
"Unsafe redirect to URL with protocol '%s'" % parsed.scheme
diff --git django/utils/archive.py django/utils/archive.py
index 71ec2d001..e8af690e2 100644
--- django/utils/archive.py
+++ django/utils/archive.py
@@ -144,7 +144,11 @@ class BaseArchive:
def target_filename(self, to_path, name):
target_path = os.path.abspath(to_path)
filename = os.path.abspath(os.path.join(target_path, name))
- if not filename.startswith(target_path):
+ try:
+ if os.path.commonpath([target_path, filename]) != target_path:
+ raise SuspiciousOperation("Archive contains invalid path:
'%s'" % name)
+ except ValueError:
+ # Different drives on Windows raises ValueError.
raise SuspiciousOperation("Archive contains invalid path: '%s'" %
name)
return filename
diff --git django/utils/html.py django/utils/html.py
index 84c37d118..11ffd53eb 100644
--- django/utils/html.py
+++ django/utils/html.py
@@ -9,12 +9,11 @@ from urllib.parse import parse_qsl, quote, unquote,
urlencode, urlsplit, urlunsp
from django.core.exceptions import SuspiciousOperation
from django.utils.encoding import punycode
from django.utils.functional import Promise, cached_property, keep_lazy,
keep_lazy_text
-from django.utils.http import RFC3986_GENDELIMS, RFC3986_SUBDELIMS
+from django.utils.http import MAX_URL_LENGTH, RFC3986_GENDELIMS,
RFC3986_SUBDELIMS
from django.utils.regex_helper import _lazy_re_compile
from django.utils.safestring import SafeData, SafeString, mark_safe
from django.utils.text import normalize_newlines
-MAX_URL_LENGTH = 2048
MAX_STRIP_TAGS_DEPTH = 50
# HTML tag that opens but has no closing ">" after 1k+ chars.
diff --git django/utils/http.py django/utils/http.py
index 94ad60bdb..0804937f3 100644
--- django/utils/http.py
+++ django/utils/http.py
@@ -47,6 +47,8 @@ ASCTIME_DATE = _lazy_re_compile(r"^\w{3} %s %s %s %s$" %
(__M, __D2, __T, __Y))
RFC3986_GENDELIMS = ":/?#[]@"
RFC3986_SUBDELIMS = "!$&'()*+,;="
+MAX_URL_LENGTH = 2048
+MAX_URL_REDIRECT_LENGTH = 16384
# TODO: Remove when dropping support for PY38.
# Unsafe bytes to be removed per WHATWG spec.
diff --git docs/internals/contributing/writing-code/submitting-patches.txt
docs/internals/contributing/writing-code/submitting-patches.txt
index be031f1f6..0a9d6b0d1 100644
--- docs/internals/contributing/writing-code/submitting-patches.txt
+++ docs/internals/contributing/writing-code/submitting-patches.txt
@@ -320,8 +320,8 @@ All code changes
* Does the :doc:`coding style
</internals/contributing/writing-code/coding-style>` conform to our
- guidelines? Are there any ``black``, ``blacken-docs``, ``flake8``, or
- ``isort`` errors? You can install the :ref:`pre-commit
+ guidelines? Are there any ``black``, ``blacken-docs``, ``flake8``,
+ ``isort``, or ``zizmor`` errors? You can install the :ref:`pre-commit
<coding-style-pre-commit>` hooks to automatically catch these errors.
* If the change is backwards incompatible in any way, is there a note
in the release notes (``docs/releases/A.B.txt``)?
diff --git docs/internals/contributing/writing-code/unit-tests.txt
docs/internals/contributing/writing-code/unit-tests.txt
index 874038a55..d9f8a276e 100644
--- docs/internals/contributing/writing-code/unit-tests.txt
+++ docs/internals/contributing/writing-code/unit-tests.txt
@@ -69,7 +69,7 @@ command from any place in the Django source tree:
$ tox
By default, ``tox`` runs the test suite with the bundled test settings file for
-SQLite, ``black``, ``blacken-docs``, ``flake8``, ``isort``, and the
+SQLite, ``black``, ``blacken-docs``, ``flake8``, ``isort``, ``zizmor``, and the
documentation spelling checker. In addition to the system dependencies noted
elsewhere in this documentation, the command ``python3`` must be on your path
and linked to the appropriate version of Python. A list of default environments
@@ -84,6 +84,7 @@ can be seen as follows:
flake8>=3.7.0
docs
isort>=5.1.0
+ zizmor>=1.16.3
Testing other Python versions and database backends
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -284,7 +285,7 @@ dependencies:
* :pypi:`asgiref` 3.6.0+ (required)
* :pypi:`bcrypt`
* :pypi:`colorama`
-* :pypi:`docutils`
+* :pypi:`docutils` <0.22
* :pypi:`geoip2`
* :pypi:`Jinja2` 2.11+
* :pypi:`numpy`
diff --git docs/ref/contrib/admin/admindocs.txt
docs/ref/contrib/admin/admindocs.txt
index cc121a7be..9806aa243 100644
--- docs/ref/contrib/admin/admindocs.txt
+++ docs/ref/contrib/admin/admindocs.txt
@@ -23,7 +23,8 @@ the following:
your ``urlpatterns``. Make sure it's included *before* the
``'admin/'`` entry, so that requests to ``/admin/doc/`` don't get
handled by the latter entry.
-* Install the docutils Python module (https://docutils.sourceforge.io/).
+* Install the docutils Python module version <0.22
+ (https://docutils.sourceforge.io/).
* **Optional:** Using the admindocs bookmarklets requires
``django.contrib.admindocs.middleware.XViewMiddleware`` to be installed.
diff --git docs/releases/4.2.24.txt docs/releases/4.2.24.txt
new file mode 100644
index 000000000..e50148391
--- /dev/null
+++ docs/releases/4.2.24.txt
@@ -0,0 +1,14 @@
+===========================
+Django 4.2.24 release notes
+===========================
+
+*September 3, 2025*
+
+Django 4.2.24 fixes a security issue with severity "high" in 4.2.23.
+
+CVE-2025-57833: Potential SQL injection in ``FilteredRelation`` column aliases
+==============================================================================
+
+:class:`.FilteredRelation` was subject to SQL injection in column aliases,
+using a suitably crafted dictionary, with dictionary expansion, as the
+``**kwargs`` passed to :meth:`.QuerySet.annotate` or :meth:`.QuerySet.alias`.
diff --git docs/releases/4.2.25.txt docs/releases/4.2.25.txt
new file mode 100644
index 000000000..7ba23c013
--- /dev/null
+++ docs/releases/4.2.25.txt
@@ -0,0 +1,25 @@
+===========================
+Django 4.2.25 release notes
+===========================
+
+*October 1, 2025*
+
+Django 4.2.25 fixes one security issue with severity "high" and one security
+issue with severity "low" in 4.2.24.
+
+CVE-2025-59681: Potential SQL injection in ``QuerySet.annotate()``,
``alias()``, ``aggregate()``, and ``extra()`` on MySQL and MariaDB
+======================================================================================================================================
+
+:meth:`.QuerySet.annotate`, :meth:`~.QuerySet.alias`,
+:meth:`~.QuerySet.aggregate`, and :meth:`~.QuerySet.extra` methods were subject
+to SQL injection in column aliases, using a suitably crafted dictionary, with
+dictionary expansion, as the ``**kwargs`` passed to these methods (follow up to
+:cve:`2022-28346`).
+
+CVE-2025-59682: Potential partial directory-traversal via ``archive.extract()``
+===============================================================================
+
+The ``django.utils.archive.extract()`` function, used by
+:option:`startapp --template` and :option:`startproject --template`, allowed
+partial directory-traversal via an archive with file paths sharing a common
+prefix with the target directory (follow up to :cve:`2021-3281`).
diff --git docs/releases/4.2.26.txt docs/releases/4.2.26.txt
new file mode 100644
index 000000000..20cf48f05
--- /dev/null
+++ docs/releases/4.2.26.txt
@@ -0,0 +1,25 @@
+===========================
+Django 4.2.26 release notes
+===========================
+
+*November 5, 2025*
+
+Django 4.2.26 fixes one security issue with severity "high" and one security
+issue with severity "moderate" in 4.2.25.
+
+CVE-2025-64458: Potential denial-of-service vulnerability in
``HttpResponseRedirect`` and ``HttpResponsePermanentRedirect`` on Windows
+======================================================================================================================================
+
+Python's :func:`NFKC normalization <python:unicodedata.normalize>` is slow on
+Windows. As a consequence, :class:`~django.http.HttpResponseRedirect`,
+:class:`~django.http.HttpResponsePermanentRedirect`, and the shortcut
+:func:`redirect() <django.shortcuts.redirect>` were subject to a potential
+denial-of-service attack via certain inputs with a very large number of Unicode
+characters (follow up to :cve:`2025-27556`).
+
+CVE-2025-64459: Potential SQL injection via ``_connector`` keyword argument
+===========================================================================
+
+:meth:`.QuerySet.filter`, :meth:`~.QuerySet.exclude`, :meth:`~.QuerySet.get`,
+and :class:`~.Q` were subject to SQL injection using a suitably crafted
+dictionary, with dictionary expansion, as the ``_connector`` argument.
diff --git docs/releases/4.2.27.txt docs/releases/4.2.27.txt
new file mode 100644
index 000000000..b843f6a44
--- /dev/null
+++ docs/releases/4.2.27.txt
@@ -0,0 +1,34 @@
+===========================
+Django 4.2.27 release notes
+===========================
+
+*December 2, 2025*
+
+Django 4.2.27 fixes one security issue with severity "high", one security issue
+with severity "moderate", and one bug in 4.2.26.
+
+CVE-2025-13372: Potential SQL injection in ``FilteredRelation`` column aliases
on PostgreSQL
+============================================================================================
+
+:class:`.FilteredRelation` was subject to SQL injection in column aliases,
+using a suitably crafted dictionary, with dictionary expansion, as the
+``**kwargs`` passed to :meth:`.QuerySet.annotate` or :meth:`.QuerySet.alias` on
+PostgreSQL.
+
+CVE-2025-64460: Potential denial-of-service vulnerability in XML
``Deserializer``
+=================================================================================
+
+:ref:`XML Serialization <serialization-formats-xml>` was subject to a potential
+denial-of-service attack due to quadratic time complexity when deserializing
+crafted documents containing many nested invalid elements. The internal helper
+``django.core.serializers.xml_serializer.getInnerText()`` previously
+accumulated inner text inefficiently during recursion. It now collects text per
+element, avoiding excessive resource usage.
+
+Bugfixes
+========
+
+* Fixed a regression in Django 4.2.26 where ``DisallowedRedirect`` was raised
+ by :class:`~django.http.HttpResponseRedirect` and
+ :class:`~django.http.HttpResponsePermanentRedirect` for URLs longer than 2048
+ characters. The limit is now 16384 characters (:ticket:`36743`).
diff --git docs/releases/index.txt docs/releases/index.txt
index 73195b535..f5a6770bc 100644
--- docs/releases/index.txt
+++ docs/releases/index.txt
@@ -26,6 +26,10 @@ versions of the documentation contain the release notes for
any later releases.
.. toctree::
:maxdepth: 1
+ 4.2.27
+ 4.2.26
+ 4.2.25
+ 4.2.24
4.2.23
4.2.22
4.2.21
diff --git docs/releases/security.txt docs/releases/security.txt
index de8fc96d6..e5b9878ab 100644
--- docs/releases/security.txt
+++ docs/releases/security.txt
@@ -36,6 +36,65 @@ Issues under Django's security process
All security issues have been handled under versions of Django's security
process. These are listed below.
+November 5, 2025 - :cve:`2025-64458`
+------------------------------------
+
+Potential denial-of-service vulnerability in ``HttpResponseRedirect`` and
+``HttpResponsePermanentRedirect`` on Windows. `Full description
+<https://www.djangoproject.com/weblog/2025/nov/05/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <6e13348436fccf8f22982921d6a3a3e65c956a9f>`
+* Django 5.2 :commit:`(patch) <4f5d904b63751dea9ffc3b0e046404a7fa5881ac>`
+* Django 5.1 :commit:`(patch) <3790593781d26168e7306b5b2f8ea0309de16242>`
+* Django 4.2 :commit:`(patch) <770eea38d7a0e9ba9455140b5a9a9e33618226a7>`
+
+November 5, 2025 - :cve:`2025-64459`
+------------------------------------
+
+Potential SQL injection via ``_connector`` keyword argument in ``QuerySet`` and
+``Q`` objects. `Full description
+<https://www.djangoproject.com/weblog/2025/nov/05/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <06dd38324ac3d60d83d9f3adabf0dcdf423d2a85>`
+* Django 5.2 :commit:`(patch) <6703f364d767e949c5b0e4016433ef75063b4f9b>`
+* Django 5.1 :commit:`(patch) <72d2c87431f2ae0431d65d0ec792047f078c8241>`
+* Django 4.2 :commit:`(patch) <59ae82e67053d281ff4562a24bbba21299f0a7d4>`
+
+October 1, 2025 - :cve:`2025-59681`
+-----------------------------------
+
+Potential SQL injection in ``QuerySet.annotate()``, ``alias()``,
+``aggregate()``, and ``extra()`` on MySQL and MariaDB. `Full description
+<https://www.djangoproject.com/weblog/2025/oct/01/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <4ceaaee7e04b416fc465e838a6ef43ca0ccffafe>`
+* Django 5.2 :commit:`(patch) <52fbae0a4dbbe5faa59827f8f05694a0065cc135>`
+* Django 5.1 :commit:`(patch) <01d2d770e22bffe53c7f1e611e2bbca94cb8a2e7>`
+* Django 4.2 :commit:`(patch) <38d9ef8c7b5cb6ef51b933e51a20e0e0063f33d5>`
+
+October 1, 2025 - :cve:`2025-59682`
+-----------------------------------
+
+Potential partial directory-traversal via ``archive.extract()``.
+`Full description
+<https://www.djangoproject.com/weblog/2025/oct/01/security-releases/>`__
+
+* Django 6.0 :commit:`(patch) <af067f56c1dd467df4abd0ddd409a700da1f03ba>`
+* Django 5.2 :commit:`(patch) <ed8fc39d77465eddbde1191a054ae965f6a8a584>`
+* Django 5.1 :commit:`(patch) <74fa85c688a87224637155902bcd738bb9e65e11>`
+* Django 4.2 :commit:`(patch) <9504bbaa392c9fe37eee9291f5b4c29eb6037619>`
+
+September 3, 2025 - :cve:`2025-57833`
+-------------------------------------
+
+Potential SQL injection in ``FilteredRelation`` column aliases.
+`Full description
+<https://www.djangoproject.com/weblog/2025/sep/03/security-releases/>`__
+
+* Django 5.2 :commit:`(patch) <4c044fcc866ec226f612c475950b690b0139d243>`
+* Django 5.1 :commit:`(patch) <102965ea93072fe3c39a30be437c683ec1106ef5>`
+* Django 4.2 :commit:`(patch) <31334e6965ad136a5e369993b01721499c5d1a92>`
+
June 4, 2025 - :cve:`2025-48432`
--------------------------------
@@ -47,6 +106,14 @@ Potential log injection via unescaped request path.
* Django 5.1 :commit:`(patch) <596542ddb46cdabe011322917e1655f0d24eece2>`
* Django 4.2 :commit:`(patch) <ac03c5e7df8680c61cdb0d3bdb8be9095dba841e>`
+There was an additional hardening with new patch releases published on June 10,
+2025. `Full description
+<https://www.djangoproject.com/weblog/2025/jun/10/bugfix-releases/>`__
+
+* Django 5.2.3 :commit:`(patch) <8fcc83953c350e158a484bf1da0aa1b79b69bb07>`
+* Django 5.1.11 :commit:`(patch) <31f4bd31fa16f7f5302f65b9b8b7a49b69a7c4a6>`
+* Django 4.2.23 :commit:`(patch) <b597d46bb19c8567615e62029210dab16c70db7d>`
+
May 7, 2025 - :cve:`2025-32873`
-------------------------------
diff --git docs/topics/serialization.txt docs/topics/serialization.txt
index 0bb57642a..dc403ca1d 100644
--- docs/topics/serialization.txt
+++ docs/topics/serialization.txt
@@ -173,6 +173,8 @@ Identifier Information
.. _jsonl: https://jsonlines.org/
.. _PyYAML: https://pyyaml.org/
+.. _serialization-formats-xml:
+
XML
---
diff --git scripts/manage_translations.py scripts/manage_translations.py
deleted file mode 100644
index 5b82011f2..000000000
--- scripts/manage_translations.py
+++ /dev/null
@@ -1,219 +0,0 @@
-#!/usr/bin/env python
-#
-# This Python file contains utility scripts to manage Django translations.
-# It has to be run inside the django git root directory.
-#
-# The following commands are available:
-#
-# * update_catalogs: check for new strings in core and contrib catalogs, and
-# output how much strings are new/changed.
-#
-# * lang_stats: output statistics for each catalog/language combination
-#
-# * fetch: fetch translations from transifex.com
-#
-# Each command support the --languages and --resources options to limit their
-# operation to the specified language or resource. For example, to get stats
-# for Spanish in contrib.admin, run:
-#
-# $ python scripts/manage_translations.py lang_stats --language=es
--resources=admin
-
-import os
-from argparse import ArgumentParser
-from subprocess import run
-
-import django
-from django.conf import settings
-from django.core.management import call_command
-
-HAVE_JS = ["admin"]
-
-
-def _get_locale_dirs(resources, include_core=True):
- """
- Return a tuple (contrib name, absolute path) for all locale directories,
- optionally including the django core catalog.
- If resources list is not None, filter directories matching resources
content.
- """
- contrib_dir = os.path.join(os.getcwd(), "django", "contrib")
- dirs = []
-
- # Collect all locale directories
- for contrib_name in os.listdir(contrib_dir):
- path = os.path.join(contrib_dir, contrib_name, "locale")
- if os.path.isdir(path):
- dirs.append((contrib_name, path))
- if contrib_name in HAVE_JS:
- dirs.append(("%s-js" % contrib_name, path))
- if include_core:
- dirs.insert(0, ("core", os.path.join(os.getcwd(), "django", "conf",
"locale")))
-
- # Filter by resources, if any
- if resources is not None:
- res_names = [d[0] for d in dirs]
- dirs = [ld for ld in dirs if ld[0] in resources]
- if len(resources) > len(dirs):
- print(
- "You have specified some unknown resources. "
- "Available resource names are: %s" % (", ".join(res_names),)
- )
- exit(1)
- return dirs
-
-
-def _tx_resource_for_name(name):
- """Return the Transifex resource name"""
- if name == "core":
- return "django.core"
- else:
- return "django.contrib-%s" % name
-
-
-def _check_diff(cat_name, base_path):
- """
- Output the approximate number of changed/added strings in the en catalog.
- """
- po_path = "%(path)s/en/LC_MESSAGES/django%(ext)s.po" % {
- "path": base_path,
- "ext": "js" if cat_name.endswith("-js") else "",
- }
- p = run(
- "git diff -U0 %s | egrep '^[-+]msgid' | wc -l" % po_path,
- capture_output=True,
- shell=True,
- )
- num_changes = int(p.stdout.strip())
- print("%d changed/added messages in '%s' catalog." % (num_changes,
cat_name))
-
-
-def update_catalogs(resources=None, languages=None):
- """
- Update the en/LC_MESSAGES/django.po (main and contrib) files with
- new/updated translatable strings.
- """
- settings.configure()
- django.setup()
- if resources is not None:
- print("`update_catalogs` will always process all resources.")
- contrib_dirs = _get_locale_dirs(None, include_core=False)
-
- os.chdir(os.path.join(os.getcwd(), "django"))
- print("Updating en catalogs for Django and contrib apps...")
- call_command("makemessages", locale=["en"])
- print("Updating en JS catalogs for Django and contrib apps...")
- call_command("makemessages", locale=["en"], domain="djangojs")
-
- # Output changed stats
- _check_diff("core", os.path.join(os.getcwd(), "conf", "locale"))
- for name, dir_ in contrib_dirs:
- _check_diff(name, dir_)
-
-
-def lang_stats(resources=None, languages=None):
- """
- Output language statistics of committed translation files for each
- Django catalog.
- If resources is provided, it should be a list of translation resource to
- limit the output (e.g. ['core', 'gis']).
- """
- locale_dirs = _get_locale_dirs(resources)
-
- for name, dir_ in locale_dirs:
- print("\nShowing translations stats for '%s':" % name)
- langs = sorted(d for d in os.listdir(dir_) if not d.startswith("_"))
- for lang in langs:
- if languages and lang not in languages:
- continue
- # TODO: merge first with the latest en catalog
- po_path = "{path}/{lang}/LC_MESSAGES/django{ext}.po".format(
- path=dir_, lang=lang, ext="js" if name.endswith("-js") else ""
- )
- p = run(
- ["msgfmt", "-vc", "-o", "/dev/null", po_path],
- capture_output=True,
- env={"LANG": "C"},
- encoding="utf-8",
- )
- if p.returncode == 0:
- # msgfmt output stats on stderr
- print("%s: %s" % (lang, p.stderr.strip()))
- else:
- print(
- "Errors happened when checking %s translation for %s:\n%s"
- % (lang, name, p.stderr)
- )
-
-
-def fetch(resources=None, languages=None):
- """
- Fetch translations from Transifex, wrap long lines, generate mo files.
- """
- locale_dirs = _get_locale_dirs(resources)
- errors = []
-
- for name, dir_ in locale_dirs:
- # Transifex pull
- if languages is None:
- run(
- [
- "tx",
- "pull",
- "-r",
- _tx_resource_for_name(name),
- "-a",
- "-f",
- "--minimum-perc=5",
- ]
- )
- target_langs = sorted(
- d for d in os.listdir(dir_) if not d.startswith("_") and d !=
"en"
- )
- else:
- for lang in languages:
- run(["tx", "pull", "-r", _tx_resource_for_name(name), "-f",
"-l", lang])
- target_langs = languages
-
- # msgcat to wrap lines and msgfmt for compilation of .mo file
- for lang in target_langs:
- po_path = "%(path)s/%(lang)s/LC_MESSAGES/django%(ext)s.po" % {
- "path": dir_,
- "lang": lang,
- "ext": "js" if name.endswith("-js") else "",
- }
- if not os.path.exists(po_path):
- print(
- "No %(lang)s translation for resource %(name)s"
- % {"lang": lang, "name": name}
- )
- continue
- run(["msgcat", "--no-location", "-o", po_path, po_path])
- msgfmt = run(["msgfmt", "-c", "-o", "%s.mo" % po_path[:-3],
po_path])
- if msgfmt.returncode != 0:
- errors.append((name, lang))
- if errors:
- print("\nWARNING: Errors have occurred in following cases:")
- for resource, lang in errors:
- print("\tResource %s for language %s" % (resource, lang))
- exit(1)
-
-
-if __name__ == "__main__":
- RUNABLE_SCRIPTS = ("update_catalogs", "lang_stats", "fetch")
-
- parser = ArgumentParser()
- parser.add_argument("cmd", nargs=1, choices=RUNABLE_SCRIPTS)
- parser.add_argument(
- "-r",
- "--resources",
- action="append",
- help="limit operation to the specified resources",
- )
- parser.add_argument(
- "-l",
- "--languages",
- action="append",
- help="limit operation to the specified languages",
- )
- options = parser.parse_args()
-
- eval(options.cmd[0])(options.resources, options.languages)
diff --git tests/aggregation/tests.py tests/aggregation/tests.py
index 48266d977..277c0507f 100644
--- tests/aggregation/tests.py
+++ tests/aggregation/tests.py
@@ -2090,8 +2090,8 @@ class AggregateTestCase(TestCase):
def test_alias_sql_injection(self):
crafted_alias = """injected_name" from "aggregation_author"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, quotation
marks, "
- "semicolons, or SQL comments."
+ "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")})
diff --git tests/annotations/tests.py tests/annotations/tests.py
index e0cdbf1e0..d876e3a6f 100644
--- tests/annotations/tests.py
+++ tests/annotations/tests.py
@@ -2,6 +2,7 @@ import datetime
from decimal import Decimal
from django.core.exceptions import FieldDoesNotExist, FieldError
+from django.db import connection
from django.db.models import (
BooleanField,
Case,
@@ -12,6 +13,7 @@ from django.db.models import (
Exists,
ExpressionWrapper,
F,
+ FilteredRelation,
FloatField,
Func,
IntegerField,
@@ -1115,12 +1117,21 @@ class NonAggregateAnnotationTestCase(TestCase):
def test_alias_sql_injection(self):
crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, quotation
marks, "
- "semicolons, or SQL comments."
+ "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)})
+ 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")})
+
def test_alias_forbidden_chars(self):
tests = [
'al"ias',
@@ -1133,19 +1144,25 @@ class NonAggregateAnnotationTestCase(TestCase):
"ali/*as",
"alias*/",
"alias;",
- # [] are used by MSSQL.
+ # [] and # are used by MSSQL.
"alias[",
"alias]",
+ "ali#as",
]
msg = (
- "Column aliases cannot contain whitespace characters, quotation
marks, "
- "semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes,
quotation "
+ "marks, semicolons, or SQL comments."
)
for crafted_alias in tests:
with self.subTest(crafted_alias):
with self.assertRaisesMessage(ValueError, msg):
Book.objects.annotate(**{crafted_alias: Value(1)})
+ with self.assertRaisesMessage(ValueError, msg):
+ Book.objects.annotate(
+ **{crafted_alias: FilteredRelation("authors")}
+ )
+
class AliasTests(TestCase):
@classmethod
@@ -1413,8 +1430,28 @@ class AliasTests(TestCase):
def test_alias_sql_injection(self):
crafted_alias = """injected_name" from "annotations_book"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, quotation
marks, "
- "semicolons, or SQL comments."
+ "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)})
+
+ 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")})
+
+ def test_alias_filtered_relation_sql_injection_dollar_sign(self):
+ qs = Book.objects.alias(
+ **{"crafted_alia$": FilteredRelation("authors")}
+ ).values("name", "crafted_alia$")
+ if connection.vendor == "postgresql":
+ msg = "Dollar signs are not permitted in column aliases on
PostgreSQL."
+ with self.assertRaisesMessage(ValueError, msg):
+ list(qs)
+ else:
+ self.assertEqual(qs.first()["name"], self.b1.name)
diff --git tests/expressions/test_queryset_values.py
tests/expressions/test_queryset_values.py
index 47bd1358d..080ee0618 100644
--- tests/expressions/test_queryset_values.py
+++ tests/expressions/test_queryset_values.py
@@ -37,8 +37,8 @@ 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, quotation
marks, "
- "semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes,
quotation "
+ "marks, semicolons, or SQL comments."
)
with self.assertRaisesMessage(ValueError, msg):
Company.objects.values(**{crafted_alias: F("ceo__salary")})
@@ -47,8 +47,8 @@ class ValuesExpressionsTests(TestCase):
def test_values_expression_alias_sql_injection_json_field(self):
crafted_alias = """injected_name" from "expressions_company"; --"""
msg = (
- "Column aliases cannot contain whitespace characters, quotation
marks, "
- "semicolons, or SQL comments."
+ "Column aliases cannot contain whitespace characters, hashes,
quotation "
+ "marks, semicolons, or SQL comments."
)
with self.assertRaisesMessage(ValueError, msg):
JSONFieldModel.objects.values(f"data__{crafted_alias}")
diff --git tests/gis_tests/gdal_tests/test_raster.py
tests/gis_tests/gdal_tests/test_raster.py
index cbe7d6b36..e1c135315 100644
--- tests/gis_tests/gdal_tests/test_raster.py
+++ tests/gis_tests/gdal_tests/test_raster.py
@@ -6,7 +6,7 @@ import zipfile
from pathlib import Path
from unittest import mock
-from django.contrib.gis.gdal import GDALRaster, SpatialReference
+from django.contrib.gis.gdal import GDAL_VERSION, GDALRaster, SpatialReference
from django.contrib.gis.gdal.error import GDALException
from django.contrib.gis.gdal.raster.band import GDALBand
from django.contrib.gis.shortcuts import numpy
@@ -406,6 +406,8 @@ class GDALRasterTests(SimpleTestCase):
self.assertIn("NAD83 / Florida GDL Albers", infos)
def test_compressed_file_based_raster_creation(self):
+ if GDAL_VERSION > (3, 4):
+ self.skipTest("GDAL_PIXEL_TYPES are missing types from GDAL 3.5+.")
rstfile = tempfile.NamedTemporaryFile(suffix=".tif")
# Make a compressed copy of an existing raster.
compressed = self.rs.warp(
diff --git tests/gis_tests/relatedapp/tests.py
tests/gis_tests/relatedapp/tests.py
index e11a410af..74aa64c0a 100644
--- tests/gis_tests/relatedapp/tests.py
+++ tests/gis_tests/relatedapp/tests.py
@@ -98,10 +98,15 @@ class RelatedGeoModelTest(TestCase):
self.assertEqual(type(u3), MultiPoint)
# Ordering of points in the result of the union is not defined and
- # implementation-dependent (DB backend, GEOS version)
- self.assertEqual({p.ewkt for p in ref_u1}, {p.ewkt for p in u1})
- self.assertEqual({p.ewkt for p in ref_u2}, {p.ewkt for p in u2})
- self.assertEqual({p.ewkt for p in ref_u1}, {p.ewkt for p in u3})
+ # implementation-dependent (DB backend, GEOS version).
+ tests = [
+ (u1, ref_u1),
+ (u2, ref_u2),
+ (u3, ref_u1),
+ ]
+ for union, ref in tests:
+ for point, ref_point in zip(sorted(union), sorted(ref)):
+ self.assertIs(point.equals_exact(ref_point, tolerance=6), True)
def test05_select_related_fk_to_subclass(self):
"""
diff --git tests/httpwrappers/tests.py tests/httpwrappers/tests.py
index fa2c8fd5d..3af20f930 100644
--- tests/httpwrappers/tests.py
+++ tests/httpwrappers/tests.py
@@ -24,6 +24,7 @@ from django.http import (
)
from django.test import SimpleTestCase
from django.utils.functional import lazystr
+from django.utils.http import MAX_URL_REDIRECT_LENGTH
class QueryDictTests(SimpleTestCase):
@@ -485,11 +486,25 @@ class HttpResponseTests(SimpleTestCase):
r.writelines(["foo\n", "bar\n", "baz\n"])
self.assertEqual(r.content, b"foo\nbar\nbaz\n")
+ def test_redirect_url_max_length(self):
+ base_url = "https://example.com/"
+ for length in (
+ MAX_URL_REDIRECT_LENGTH - 1,
+ MAX_URL_REDIRECT_LENGTH,
+ ):
+ long_url = base_url + "x" * (length - len(base_url))
+ with self.subTest(length=length):
+ response = HttpResponseRedirect(long_url)
+ self.assertEqual(response.url, long_url)
+ response = HttpResponsePermanentRedirect(long_url)
+ self.assertEqual(response.url, long_url)
+
def test_unsafe_redirect(self):
bad_urls = [
'data:text/html,<script>window.alert("xss")</script>',
"mailto:[email protected]",
"file:///etc/passwd",
+ "é" * (MAX_URL_REDIRECT_LENGTH + 1),
]
for url in bad_urls:
with self.assertRaises(DisallowedRedirect):
diff --git tests/queries/test_q.py tests/queries/test_q.py
index cdf40292b..5f20a4176 100644
--- tests/queries/test_q.py
+++ tests/queries/test_q.py
@@ -225,6 +225,11 @@ class QTests(SimpleTestCase):
Q(*items, _connector=connector),
)
+ def test_connector_validation(self):
+ msg = f"_connector must be one of {Q.AND!r}, {Q.OR!r}, {Q.XOR!r}, or
None."
+ with self.assertRaisesMessage(ValueError, msg):
+ Q(_connector="evil")
+
class QCheckTests(TestCase):
def test_basic(self):
diff --git tests/queries/tests.py tests/queries/tests.py
index a6a2b252e..2290ea29b 100644
--- tests/queries/tests.py
+++ tests/queries/tests.py
@@ -1943,8 +1943,8 @@ 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, quotation
marks, "
- "semicolons, or SQL comments."
+ "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"})
@@ -4490,6 +4490,14 @@ class TestInvalidValuesRelation(SimpleTestCase):
Annotation.objects.filter(tag__in=[123, "abc"])
+class TestInvalidFilterArguments(TestCase):
+ def test_filter_rejects_invalid_arguments(self):
+ school = School.objects.create()
+ msg = "The following kwargs are invalid: '_connector', '_negated'"
+ with self.assertRaisesMessage(TypeError, msg):
+ School.objects.filter(pk=school.pk, _negated=True,
_connector="evil")
+
+
class TestTicket24605(TestCase):
def test_ticket_24605(self):
"""
diff --git tests/requirements/py3.txt tests/requirements/py3.txt
index 7eebc20e0..b166df7bb 100644
--- tests/requirements/py3.txt
+++ tests/requirements/py3.txt
@@ -4,7 +4,7 @@ argon2-cffi >= 19.2.0
backports.zoneinfo; python_version < '3.9'
bcrypt
black == 23.12.1
-docutils
+docutils < 0.22
geoip2; python_version < '3.12'
jinja2 >= 2.11.0
numpy; python_version < '3.12'
diff --git tests/serializers/test_xml.py tests/serializers/test_xml.py
index c9df2f2a5..03462cfed 100644
--- tests/serializers/test_xml.py
+++ tests/serializers/test_xml.py
@@ -1,7 +1,10 @@
+import gc
+import time
from xml.dom import minidom
from django.core import serializers
-from django.core.serializers.xml_serializer import DTDForbidden
+from django.core.serializers.xml_serializer import Deserializer, DTDForbidden
+from django.db import models
from django.test import TestCase, TransactionTestCase
from .tests import SerializersTestBase, SerializersTransactionTestBase
@@ -90,6 +93,56 @@ class XmlSerializerTestCase(SerializersTestBase, TestCase):
with self.assertRaises(DTDForbidden):
next(serializers.deserialize("xml", xml))
+ def test_crafted_xml_performance(self):
+ """The time to process invalid inputs is not quadratic."""
+
+ def build_crafted_xml(depth, leaf_text_len):
+ nested_open = "<nested>" * depth
+ nested_close = "</nested>" * depth
+ leaf = "x" * leaf_text_len
+ field_content = f"{nested_open}{leaf}{nested_close}"
+ return f"""
+ <django-objects version="1.0">
+ <object model="contenttypes.contenttype" pk="1">
+ <field name="app_label">{field_content}</field>
+ <field name="model">m</field>
+ </object>
+ </django-objects>
+ """
+
+ def deserialize(crafted_xml):
+ iterator = Deserializer(crafted_xml)
+ gc.collect()
+
+ start_time = time.perf_counter()
+ result = list(iterator)
+ end_time = time.perf_counter()
+
+ self.assertEqual(len(result), 1)
+ self.assertIsInstance(result[0].object, models.Model)
+ return end_time - start_time
+
+ def assertFactor(label, params, factor=2):
+ factors = []
+ prev_time = None
+ for depth, length in params:
+ crafted_xml = build_crafted_xml(depth, length)
+ elapsed = deserialize(crafted_xml)
+ if prev_time is not None:
+ factors.append(elapsed / prev_time)
+ prev_time = elapsed
+
+ with self.subTest(label):
+ # Assert based on the average factor to reduce test flakiness.
+ self.assertLessEqual(sum(factors) / len(factors), factor)
+
+ assertFactor(
+ "varying depth, varying length",
+ [(50, 2000), (100, 4000), (200, 8000), (400, 16000), (800, 32000)],
+ 2,
+ )
+ assertFactor("constant depth, varying length", [(100, 1), (100,
1000)], 2)
+
class XmlSerializerTransactionTestCase(
SerializersTransactionTestBase, TransactionTestCase
diff --git tests/test_runner/test_parallel.py tests/test_runner/test_parallel.py
index eea9e4de7..5fbd0658b 100644
--- tests/test_runner/test_parallel.py
+++ tests/test_runner/test_parallel.py
@@ -21,6 +21,12 @@ class ExceptionThatFailsUnpickling(Exception):
def __init__(self, arg):
super().__init__()
+ def __reduce__(self):
+ # tblib 3.2+ makes exception subclasses picklable by default.
+ # Return (cls, ()) so the constructor fails on unpickle, preserving
+ # the needed behavior for test_pickle_errors_detection.
+ return (self.__class__, ())
+
class ParallelTestRunnerTest(SimpleTestCase):
"""
@@ -102,6 +108,8 @@ class RemoteTestResultTest(SimpleTestCase):
result = RemoteTestResult()
result._confirm_picklable(picklable_error)
+ # The exception can be pickled but not unpickled.
+ pickle.dumps(not_unpicklable_error)
msg = "__init__() missing 1 required positional argument"
with self.assertRaisesMessage(TypeError, msg):
result._confirm_picklable(not_unpicklable_error)
diff --git tests/test_utils/tests.py tests/test_utils/tests.py
index a440e7d96..e4c613e96 100644
--- tests/test_utils/tests.py
+++ tests/test_utils/tests.py
@@ -966,7 +966,7 @@ class HTMLEqualTests(SimpleTestCase):
"('Unexpected end tag `div` (Line 1, Column 6)', (1, 6))"
)
with self.assertRaisesMessage(AssertionError, error_msg):
- self.assertHTMLEqual("< div></ div>", "<div></div>")
+ self.assertHTMLEqual("< div></div>", "<div></div>")
with self.assertRaises(HTMLParseError):
parse_html("</p>")
diff --git tests/utils_tests/test_archive.py tests/utils_tests/test_archive.py
index 8cd107063..8063dafb6 100644
--- tests/utils_tests/test_archive.py
+++ tests/utils_tests/test_archive.py
@@ -3,6 +3,7 @@ import stat
import sys
import tempfile
import unittest
+import zipfile
from django.core.exceptions import SuspiciousOperation
from django.test import SimpleTestCase
@@ -96,3 +97,21 @@ class TestArchiveInvalid(SimpleTestCase):
with self.subTest(entry), tempfile.TemporaryDirectory() as tmpdir:
with self.assertRaisesMessage(SuspiciousOperation, msg %
invalid_path):
archive.extract(os.path.join(archives_dir, entry), tmpdir)
+
+ def test_extract_function_traversal_startswith(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ base = os.path.abspath(tmpdir)
+ tarfile_handle = tempfile.NamedTemporaryFile(suffix=".zip",
delete=False)
+ tar_path = tarfile_handle.name
+ tarfile_handle.close()
+ self.addCleanup(os.remove, tar_path)
+
+ malicious_member = os.path.join(base + "abc", "evil.txt")
+ with zipfile.ZipFile(tar_path, "w") as zf:
+ zf.writestr(malicious_member, "evil\n")
+ zf.writestr("test.txt", "data\n")
+
+ with self.assertRaisesMessage(
+ SuspiciousOperation, "Archive contains invalid path"
+ ):
+ archive.extract(tar_path, base)
diff --git tests/utils_tests/test_html.py tests/utils_tests/test_html.py
index 25168e234..f755b8ceb 100644
--- tests/utils_tests/test_html.py
+++ tests/utils_tests/test_html.py
@@ -1,4 +1,5 @@
import os
+import sys
from datetime import datetime
from django.core.exceptions import SuspiciousOperation
@@ -85,6 +86,24 @@ class TestUtilsHtml(SimpleTestCase):
self.check_output(linebreaks, lazystr(value), output)
def test_strip_tags(self):
+ # Python fixed a quadratic-time issue in HTMLParser in 3.13.6, 3.12.12,
+ # 3.11.14, 3.10.19, and 3.9.24. The fix slightly changes HTMLParser's
+ # output, so tests for particularly malformed input must handle both
+ # 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 = {
+ (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),
+ }
+ py_version = sys.version_info[:2]
+ htmlparser_fixed = (
+ py_version in min_fixed and sys.version_info >=
min_fixed[py_version]
+ )
items = (
(
"<p>See: 'é is an apostrophe followed by e
acute</p>",
@@ -112,10 +131,16 @@ class TestUtilsHtml(SimpleTestCase):
("&gotcha&#;<>", "&gotcha&#;<>"),
("<sc<!-- -->ript>test<<!-- -->/script>", "ript>test"),
("<script>alert()</script>&h", "alert()h"),
- ("><!" + ("&" * 16000) + "D", "><!" + ("&" * 16000) + "D"),
+ (
+ "><!" + ("&" * 16000) + "D",
+ ">" if htmlparser_fixed else "><!" + ("&" * 16000) + "D",
+ ),
("X<<<<br>br>br>br>X", "XX"),
("<" * 50 + "a>" * 50, ""),
- (">" + "<a" * 500 + "a", ">" + "<a" * 500 + "a"),
+ (
+ ">" + "<a" * 500 + "a",
+ ">" if htmlparser_fixed else ">" + "<a" * 500 + "a",
+ ),
("<a" * 49 + "a" * 951, "<a" * 49 + "a" * 951),
("<" + "a" * 1_002, "<" + "a" * 1_002),
)
--- End Message ---