Version in base suite: 4.2.23-1 Base version: python-django_4.2.23-1 Target version: python-django_4.2.27-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-django/python-django_4.2.23-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-django/python-django_4.2.27-0+deb13u1.dsc Django.egg-info/PKG-INFO | 2 Django.egg-info/SOURCES.txt | 6 MANIFEST.in | 2 PKG-INFO | 2 debian/changelog | 49 ++ django/__init__.py | 2 django/core/serializers/xml_serializer.py | 39 + django/db/backends/postgresql/compiler.py | 24 + django/db/backends/postgresql/operations.py | 1 django/db/models/query.py | 5 django/db/models/query_utils.py | 4 django/db/models/sql/query.py | 9 django/http/response.py | 13 django/utils/archive.py | 6 django/utils/html.py | 3 django/utils/http.py | 2 docs/internals/contributing/writing-code/submitting-patches.txt | 4 docs/internals/contributing/writing-code/unit-tests.txt | 5 docs/ref/contrib/admin/admindocs.txt | 3 docs/releases/4.2.24.txt | 14 docs/releases/4.2.25.txt | 25 + docs/releases/4.2.26.txt | 25 + docs/releases/4.2.27.txt | 34 + docs/releases/index.txt | 4 docs/releases/security.txt | 67 +++ docs/topics/serialization.txt | 2 scripts/manage_translations.py | 219 ---------- tests/aggregation/tests.py | 4 tests/annotations/tests.py | 51 ++ tests/expressions/test_queryset_values.py | 8 tests/gis_tests/gdal_tests/test_raster.py | 4 tests/gis_tests/relatedapp/tests.py | 13 tests/httpwrappers/tests.py | 15 tests/queries/test_q.py | 5 tests/queries/tests.py | 12 tests/requirements/py3.txt | 2 tests/serializers/test_xml.py | 55 ++ tests/test_runner/test_parallel.py | 8 tests/test_utils/tests.py | 2 tests/utils_tests/test_archive.py | 19 tests/utils_tests/test_html.py | 29 + 41 files changed, 529 insertions(+), 269 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpi9i1t5kh/python-django_4.2.23-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpi9i1t5kh/python-django_4.2.27-0+deb13u1.dsc: no acceptable signature found diff -Nru python-django-4.2.23/Django.egg-info/PKG-INFO python-django-4.2.27/Django.egg-info/PKG-INFO --- python-django-4.2.23/Django.egg-info/PKG-INFO 2025-06-10 09:58:08.000000000 +0000 +++ python-django-4.2.27/Django.egg-info/PKG-INFO 2025-12-02 12:46:18.000000000 +0000 @@ -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 License: BSD-3-Clause diff -Nru python-django-4.2.23/Django.egg-info/SOURCES.txt python-django-4.2.27/Django.egg-info/SOURCES.txt --- python-django-4.2.23/Django.egg-info/SOURCES.txt 2025-06-10 09:58:09.000000000 +0000 +++ python-django-4.2.27/Django.egg-info/SOURCES.txt 2025-12-02 12:46:18.000000000 +0000 @@ -3315,6 +3315,7 @@ 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.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/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 -Nru python-django-4.2.23/MANIFEST.in python-django-4.2.27/MANIFEST.in --- python-django-4.2.23/MANIFEST.in 2025-06-10 09:51:06.000000000 +0000 +++ python-django-4.2.27/MANIFEST.in 2025-12-02 12:43:50.000000000 +0000 @@ -10,6 +10,6 @@ graft docs graft extras graft js_tests -graft scripts graft tests global-exclude *.py[co] +prune scripts diff -Nru python-django-4.2.23/PKG-INFO python-django-4.2.27/PKG-INFO --- python-django-4.2.23/PKG-INFO 2025-06-10 09:58:09.710780100 +0000 +++ python-django-4.2.27/PKG-INFO 2025-12-02 12:46:19.003385000 +0000 @@ -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 License: BSD-3-Clause diff -Nru python-django-4.2.23/debian/changelog python-django-4.2.27/debian/changelog --- python-django-4.2.23/debian/changelog 2025-06-10 16:37:03.000000000 +0000 +++ python-django-4.2.27/debian/changelog 2026-01-23 18:43:29.000000000 +0000 @@ -1,3 +1,52 @@ +python-django (3:4.2.27-0+deb13u1) trixie-security; 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) + + + + -- Chris Lamb 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 -Nru python-django-4.2.23/django/__init__.py python-django-4.2.27/django/__init__.py --- python-django-4.2.23/django/__init__.py 2025-06-10 09:51:53.000000000 +0000 +++ python-django-4.2.27/django/__init__.py 2025-12-02 12:45:49.000000000 +0000 @@ -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 -Nru python-django-4.2.23/django/core/serializers/xml_serializer.py python-django-4.2.27/django/core/serializers/xml_serializer.py --- python-django-4.2.23/django/core/serializers/xml_serializer.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/core/serializers/xml_serializer.py 2025-12-02 12:44:40.000000000 +0000 @@ -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.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__ == "" + 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 @@ 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 @@ 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 -Nru python-django-4.2.23/django/db/backends/postgresql/compiler.py python-django-4.2.27/django/db/backends/postgresql/compiler.py --- python-django-4.2.23/django/db/backends/postgresql/compiler.py 1970-01-01 00:00:00.000000000 +0000 +++ python-django-4.2.27/django/db/backends/postgresql/compiler.py 2025-12-02 12:44:19.000000000 +0000 @@ -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 -Nru python-django-4.2.23/django/db/backends/postgresql/operations.py python-django-4.2.27/django/db/backends/postgresql/operations.py --- python-django-4.2.23/django/db/backends/postgresql/operations.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/db/backends/postgresql/operations.py 2025-12-02 12:44:19.000000000 +0000 @@ -23,6 +23,7 @@ class DatabaseOperations(BaseDatabaseOperations): + compiler_module = "django.db.backends.postgresql.compiler" cast_char_field_without_max_length = "varchar" explain_prefix = "EXPLAIN" explain_options = frozenset( diff -Nru python-django-4.2.23/django/db/models/query.py python-django-4.2.27/django/db/models/query.py --- python-django-4.2.23/django/db/models/query.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/db/models/query.py 2025-12-02 12:43:50.000000000 +0000 @@ -42,6 +42,8 @@ # 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 @@ 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 -Nru python-django-4.2.23/django/db/models/query_utils.py python-django-4.2.27/django/db/models/query_utils.py --- python-django-4.2.23/django/db/models/query_utils.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/db/models/query_utils.py 2025-12-02 12:43:50.000000000 +0000 @@ -44,8 +44,12 @@ 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 -Nru python-django-4.2.23/django/db/models/sql/query.py python-django-4.2.27/django/db/models/sql/query.py --- python-django-4.2.23/django/db/models/sql/query.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/db/models/sql/query.py 2025-12-02 12:43:50.000000000 +0000 @@ -46,9 +46,9 @@ __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 @@ 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 @@ 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 -Nru python-django-4.2.23/django/http/response.py python-django-4.2.27/django/http/response.py --- python-django-4.2.23/django/http/response.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/http/response.py 2025-12-02 12:43:50.000000000 +0000 @@ -21,7 +21,11 @@ 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 @@ 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 -Nru python-django-4.2.23/django/utils/archive.py python-django-4.2.27/django/utils/archive.py --- python-django-4.2.23/django/utils/archive.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/utils/archive.py 2025-12-02 12:43:50.000000000 +0000 @@ -144,7 +144,11 @@ 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 -Nru python-django-4.2.23/django/utils/html.py python-django-4.2.27/django/utils/html.py --- python-django-4.2.23/django/utils/html.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/utils/html.py 2025-12-02 12:43:50.000000000 +0000 @@ -9,12 +9,11 @@ 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 -Nru python-django-4.2.23/django/utils/http.py python-django-4.2.27/django/utils/http.py --- python-django-4.2.23/django/utils/http.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/django/utils/http.py 2025-12-02 12:43:50.000000000 +0000 @@ -47,6 +47,8 @@ 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 -Nru python-django-4.2.23/docs/internals/contributing/writing-code/submitting-patches.txt python-django-4.2.27/docs/internals/contributing/writing-code/submitting-patches.txt --- python-django-4.2.23/docs/internals/contributing/writing-code/submitting-patches.txt 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/docs/internals/contributing/writing-code/submitting-patches.txt 2025-12-02 12:43:50.000000000 +0000 @@ -320,8 +320,8 @@ * Does the :doc:`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 ` 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 -Nru python-django-4.2.23/docs/internals/contributing/writing-code/unit-tests.txt python-django-4.2.27/docs/internals/contributing/writing-code/unit-tests.txt --- python-django-4.2.23/docs/internals/contributing/writing-code/unit-tests.txt 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/docs/internals/contributing/writing-code/unit-tests.txt 2025-12-02 12:43:50.000000000 +0000 @@ -69,7 +69,7 @@ $ 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 @@ flake8>=3.7.0 docs isort>=5.1.0 + zizmor>=1.16.3 Testing other Python versions and database backends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -284,7 +285,7 @@ * :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 -Nru python-django-4.2.23/docs/ref/contrib/admin/admindocs.txt python-django-4.2.27/docs/ref/contrib/admin/admindocs.txt --- python-django-4.2.23/docs/ref/contrib/admin/admindocs.txt 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/docs/ref/contrib/admin/admindocs.txt 2025-12-02 12:43:50.000000000 +0000 @@ -23,7 +23,8 @@ 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 -Nru python-django-4.2.23/docs/releases/4.2.24.txt python-django-4.2.27/docs/releases/4.2.24.txt --- python-django-4.2.23/docs/releases/4.2.24.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-4.2.27/docs/releases/4.2.24.txt 2025-10-22 18:22:16.000000000 +0000 @@ -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 -Nru python-django-4.2.23/docs/releases/4.2.25.txt python-django-4.2.27/docs/releases/4.2.25.txt --- python-django-4.2.23/docs/releases/4.2.25.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-4.2.27/docs/releases/4.2.25.txt 2025-10-22 18:22:16.000000000 +0000 @@ -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 -Nru python-django-4.2.23/docs/releases/4.2.26.txt python-django-4.2.27/docs/releases/4.2.26.txt --- python-django-4.2.23/docs/releases/4.2.26.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-4.2.27/docs/releases/4.2.26.txt 2025-11-05 12:53:18.000000000 +0000 @@ -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 ` is slow on +Windows. As a consequence, :class:`~django.http.HttpResponseRedirect`, +:class:`~django.http.HttpResponsePermanentRedirect`, and the shortcut +:func:`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 -Nru python-django-4.2.23/docs/releases/4.2.27.txt python-django-4.2.27/docs/releases/4.2.27.txt --- python-django-4.2.23/docs/releases/4.2.27.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-4.2.27/docs/releases/4.2.27.txt 2025-12-02 12:44:40.000000000 +0000 @@ -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 ` 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 -Nru python-django-4.2.23/docs/releases/index.txt python-django-4.2.27/docs/releases/index.txt --- python-django-4.2.23/docs/releases/index.txt 2025-06-10 09:51:08.000000000 +0000 +++ python-django-4.2.27/docs/releases/index.txt 2025-12-02 12:43:50.000000000 +0000 @@ -26,6 +26,10 @@ .. 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 -Nru python-django-4.2.23/docs/releases/security.txt python-django-4.2.27/docs/releases/security.txt --- python-django-4.2.23/docs/releases/security.txt 2025-06-10 09:51:08.000000000 +0000 +++ python-django-4.2.27/docs/releases/security.txt 2025-12-02 12:43:50.000000000 +0000 @@ -36,6 +36,65 @@ 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 +`__ + +* 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 +`__ + +* 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 +`__ + +* 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 +`__ + +* Django 6.0 :commit:`(patch) ` +* Django 5.2 :commit:`(patch) ` +* 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 +`__ + +* 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 @@ * Django 5.1 :commit:`(patch) <596542ddb46cdabe011322917e1655f0d24eece2>` * Django 4.2 :commit:`(patch) ` +There was an additional hardening with new patch releases published on June 10, +2025. `Full description +`__ + +* Django 5.2.3 :commit:`(patch) <8fcc83953c350e158a484bf1da0aa1b79b69bb07>` +* Django 5.1.11 :commit:`(patch) <31f4bd31fa16f7f5302f65b9b8b7a49b69a7c4a6>` +* Django 4.2.23 :commit:`(patch) ` + May 7, 2025 - :cve:`2025-32873` ------------------------------- diff -Nru python-django-4.2.23/docs/topics/serialization.txt python-django-4.2.27/docs/topics/serialization.txt --- python-django-4.2.23/docs/topics/serialization.txt 2025-06-10 09:47:17.000000000 +0000 +++ python-django-4.2.27/docs/topics/serialization.txt 2025-12-02 12:44:40.000000000 +0000 @@ -173,6 +173,8 @@ .. _jsonl: https://jsonlines.org/ .. _PyYAML: https://pyyaml.org/ +.. _serialization-formats-xml: + XML --- diff -Nru python-django-4.2.23/scripts/manage_translations.py python-django-4.2.27/scripts/manage_translations.py --- python-django-4.2.23/scripts/manage_translations.py 2025-06-10 09:47:17.000000000 +0000 +++ python-django-4.2.27/scripts/manage_translations.py 1970-01-01 00:00:00.000000000 +0000 @@ -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 -Nru python-django-4.2.23/tests/aggregation/tests.py python-django-4.2.27/tests/aggregation/tests.py --- python-django-4.2.23/tests/aggregation/tests.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/aggregation/tests.py 2025-12-02 12:43:50.000000000 +0000 @@ -2090,8 +2090,8 @@ 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 -Nru python-django-4.2.23/tests/annotations/tests.py python-django-4.2.27/tests/annotations/tests.py --- python-django-4.2.23/tests/annotations/tests.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/annotations/tests.py 2025-12-02 12:44:19.000000000 +0000 @@ -2,6 +2,7 @@ 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 @@ Exists, ExpressionWrapper, F, + FilteredRelation, FloatField, Func, IntegerField, @@ -1115,12 +1117,21 @@ 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 @@ "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 @@ 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 -Nru python-django-4.2.23/tests/expressions/test_queryset_values.py python-django-4.2.27/tests/expressions/test_queryset_values.py --- python-django-4.2.23/tests/expressions/test_queryset_values.py 2025-05-22 13:21:16.000000000 +0000 +++ python-django-4.2.27/tests/expressions/test_queryset_values.py 2025-12-02 12:30:32.000000000 +0000 @@ -37,8 +37,8 @@ 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 @@ 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 -Nru python-django-4.2.23/tests/gis_tests/gdal_tests/test_raster.py python-django-4.2.27/tests/gis_tests/gdal_tests/test_raster.py --- python-django-4.2.23/tests/gis_tests/gdal_tests/test_raster.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/gis_tests/gdal_tests/test_raster.py 2025-12-02 12:43:50.000000000 +0000 @@ -6,7 +6,7 @@ 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 @@ 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 -Nru python-django-4.2.23/tests/gis_tests/relatedapp/tests.py python-django-4.2.27/tests/gis_tests/relatedapp/tests.py --- python-django-4.2.23/tests/gis_tests/relatedapp/tests.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/gis_tests/relatedapp/tests.py 2025-12-02 12:43:50.000000000 +0000 @@ -98,10 +98,15 @@ 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 -Nru python-django-4.2.23/tests/httpwrappers/tests.py python-django-4.2.27/tests/httpwrappers/tests.py --- python-django-4.2.23/tests/httpwrappers/tests.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/httpwrappers/tests.py 2025-12-02 12:43:50.000000000 +0000 @@ -24,6 +24,7 @@ ) 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 @@ 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,', "mailto:test@example.com", "file:///etc/passwd", + "é" * (MAX_URL_REDIRECT_LENGTH + 1), ] for url in bad_urls: with self.assertRaises(DisallowedRedirect): diff -Nru python-django-4.2.23/tests/queries/test_q.py python-django-4.2.27/tests/queries/test_q.py --- python-django-4.2.23/tests/queries/test_q.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/queries/test_q.py 2025-12-02 12:43:50.000000000 +0000 @@ -225,6 +225,11 @@ 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 -Nru python-django-4.2.23/tests/queries/tests.py python-django-4.2.27/tests/queries/tests.py --- python-django-4.2.23/tests/queries/tests.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/queries/tests.py 2025-12-02 12:43:50.000000000 +0000 @@ -1943,8 +1943,8 @@ 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 @@ 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 -Nru python-django-4.2.23/tests/requirements/py3.txt python-django-4.2.27/tests/requirements/py3.txt --- python-django-4.2.23/tests/requirements/py3.txt 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/requirements/py3.txt 2025-12-02 12:43:50.000000000 +0000 @@ -4,7 +4,7 @@ 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 -Nru python-django-4.2.23/tests/serializers/test_xml.py python-django-4.2.27/tests/serializers/test_xml.py --- python-django-4.2.23/tests/serializers/test_xml.py 2025-06-10 09:47:17.000000000 +0000 +++ python-django-4.2.27/tests/serializers/test_xml.py 2025-12-02 12:44:40.000000000 +0000 @@ -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 @@ 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 = "" * depth + nested_close = "" * depth + leaf = "x" * leaf_text_len + field_content = f"{nested_open}{leaf}{nested_close}" + return f""" + + + {field_content} + m + + + """ + + 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 -Nru python-django-4.2.23/tests/test_runner/test_parallel.py python-django-4.2.27/tests/test_runner/test_parallel.py --- python-django-4.2.23/tests/test_runner/test_parallel.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/test_runner/test_parallel.py 2025-12-02 12:43:50.000000000 +0000 @@ -21,6 +21,12 @@ 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 @@ 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 -Nru python-django-4.2.23/tests/test_utils/tests.py python-django-4.2.27/tests/test_utils/tests.py --- python-django-4.2.23/tests/test_utils/tests.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/test_utils/tests.py 2025-12-02 12:43:50.000000000 +0000 @@ -966,7 +966,7 @@ "('Unexpected end tag `div` (Line 1, Column 6)', (1, 6))" ) with self.assertRaisesMessage(AssertionError, error_msg): - self.assertHTMLEqual("< div>", "
") + self.assertHTMLEqual("< div>", "
") with self.assertRaises(HTMLParseError): parse_html("

") diff -Nru python-django-4.2.23/tests/utils_tests/test_archive.py python-django-4.2.27/tests/utils_tests/test_archive.py --- python-django-4.2.23/tests/utils_tests/test_archive.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/utils_tests/test_archive.py 2025-12-02 12:43:50.000000000 +0000 @@ -3,6 +3,7 @@ import sys import tempfile import unittest +import zipfile from django.core.exceptions import SuspiciousOperation from django.test import SimpleTestCase @@ -96,3 +97,21 @@ 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 -Nru python-django-4.2.23/tests/utils_tests/test_html.py python-django-4.2.27/tests/utils_tests/test_html.py --- python-django-4.2.23/tests/utils_tests/test_html.py 2025-06-10 09:51:07.000000000 +0000 +++ python-django-4.2.27/tests/utils_tests/test_html.py 2025-12-02 12:43:50.000000000 +0000 @@ -1,4 +1,5 @@ import os +import sys from datetime import datetime from django.core.exceptions import SuspiciousOperation @@ -85,6 +86,24 @@ 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 = ( ( "

See: 'é is an apostrophe followed by e acute

", @@ -112,10 +131,16 @@ ("&gotcha&#;<>", "&gotcha&#;<>"), ("ript>test</script>", "ript>test"), ("&h", "alert()h"), - (">" if htmlparser_fixed else ">br>br>br>X", "XX"), ("<" * 50 + "a>" * 50, ""), - (">" + "" + "" + "" if htmlparser_fixed else ">" + "