diff --git a/Django.egg-info/PKG-INFO b/Django.egg-info/PKG-INFO index fe5c1526..96b3c40f 100644 --- a/Django.egg-info/PKG-INFO +++ b/Django.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: Django -Version: 4.2.27 +Version: 4.2.28 Summary: A high-level Python web framework that encourages rapid development and clean, pragmatic design. Author-email: Django Software Foundation License: BSD-3-Clause diff --git a/Django.egg-info/SOURCES.txt b/Django.egg-info/SOURCES.txt index c03bfd95..2e56cec1 100644 --- a/Django.egg-info/SOURCES.txt +++ b/Django.egg-info/SOURCES.txt @@ -4211,6 +4211,7 @@ docs/releases/4.2.24.txt docs/releases/4.2.25.txt docs/releases/4.2.26.txt docs/releases/4.2.27.txt +docs/releases/4.2.28.txt docs/releases/4.2.3.txt docs/releases/4.2.4.txt docs/releases/4.2.5.txt diff --git a/PKG-INFO b/PKG-INFO index fe5c1526..96b3c40f 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: Django -Version: 4.2.27 +Version: 4.2.28 Summary: A high-level Python web framework that encourages rapid development and clean, pragmatic design. Author-email: Django Software Foundation License: BSD-3-Clause diff --git a/debian/.gitignore b/debian/.gitignore deleted file mode 100644 index 2c8afebd..00000000 --- a/debian/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/files diff --git a/debian/changelog b/debian/changelog index b0c5c9d7..5247a7de 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,110 +1,89 @@ -python-django (3:4.2.27-2) unstable; urgency=medium +python-django (3:4.2.28-0+deb13u1) trixie-security; urgency=high - * Team upload. - * Backport various upstream fixes for newer Python versions (closes: - #1122185): - - Fixed tests for test --parallel option on Python 3.14+. - - Fixed copying BaseContext and its subclasses on Python 3.14+. - - Fixed OtherModelFormTests.test_prefetch_related_queryset() test on - Python 3.14+. - - Adjusted test_strip_tags following Python behavior change for - incomplete entities. - * Revert "Mark that Python 3.14 is not supported yet", since it now is. - - -- Colin Watson Wed, 17 Dec 2025 10:23:30 +0000 - -python-django (3:4.2.27-1) unstable; urgency=medium - - * 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-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. + * New upstream security release: - (Closes: #1121788)) + - CVE-2025-13473: The check_password function in + django.contrib.auth.handlers.modwsgi for authentication via mod_wsgi + allowed remote attackers to enumerate users via a timing attack. - * Mark that Python 3.14 is not supported yet. + - CVE-2025-14550: When receiving duplicates of a single header, ASGIRequest + allowed a remote attacker to cause a potential denial-of-service via a + specifically created request with multiple duplicate headers. The + vulnerability resulted from repeated string concatenation while combining + repeated headers, which produced super-linear computation resulting in + service degradation or outage. - -- Chris Lamb Tue, 02 Dec 2025 11:34:10 -0800 + - CVE-2026-1207: Raster lookups on RasterField (only implemented on + PostGIS) allowed remote attackers to inject SQL via the band index + parameter. -python-django (3:4.2.26-1) unstable; urgency=high + - CVE-2026-1285: The django.utils.text.Truncator.chars() and + Truncator.words() methods (with html=True) and the truncatechars_html and + truncatewords_html template filters allowed a remote attacker to cause a + potential denial-of-service via crafted inputs containing a large number + of unmatched HTML end tags. - * New upstream security release. - + - CVE-2026-1287: FilteredRelation was subject to SQL injection in column + aliases via control characters using a suitably crafted dictionary, with + dictionary expansion, as the **kwargs passed to QuerySet methods + annotate(), aggregate(), extra(), values(), values_list() and alias(). - - CVE-2025-64458: Fix a potential denial-of-service vulnerability in - HttpResponseRedirect and HttpResponsePermanentRedirect. NFKC - normalization in Python is slow on Windows; as a consequence, - HttpResponseRedirect, HttpResponsePermanentRedirect and redirect were - subject to a potential denial-of-service attack via certain inputs with - a very large number of Unicode characters. + - CVE-2026-1312: QuerySet.order_by() was subject to SQL injection in column + aliases containing periods when the same alias is, using a suitably + crafted dictionary, with dictionary expansion, used in FilteredRelation. - - 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. - - * Refresh patches. + (Closes: #1126914) - -- Chris Lamb Wed, 05 Nov 2025 08:36:26 -0800 + -- Chris Lamb Wed, 18 Feb 2026 14:44:14 -0800 -python-django (3:4.2.25-2) unstable; urgency=medium +python-django (3:4.2.27-0+deb13u1) trixie-security; urgency=high - * Team upload. - * Skip NOT NULL constraints on PostgreSQL 18+ (closes: #1117647). - - -- Colin Watson Wed, 22 Oct 2025 10:05:23 +0100 + * New upstream security release: -python-django (3:4.2.25-1) unstable; urgency=high + - 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(). - * New upstream security release (Closes: #1116979): + - 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. + 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() + 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. - 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. - - - - -- Chris Lamb Wed, 01 Oct 2025 11:17:18 -0700 - -python-django (3:4.2.24-1) unstable; urgency=high - - * New upstream security release: + - 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-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(). - (Closes: #1113865) + - 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 Wed, 03 Sep 2025 08:28:19 -0700 + -- Chris Lamb Fri, 23 Jan 2026 10:43:29 -0800 python-django (3:4.2.23-1) unstable; urgency=high diff --git a/debian/control b/debian/control index a5d686ff..c49cbfda 100644 --- a/debian/control +++ b/debian/control @@ -25,12 +25,12 @@ Build-Depends: python3-jinja2 , python3-numpy , python3-pil , - python3-pytz , python3-selenium , python3-setuptools, python3-sphinx, python3-sqlparse , python3-tblib , + python3-tz , python3-yaml , Build-Depends-Indep: libjs-jquery, @@ -55,6 +55,7 @@ Depends: Recommends: libjs-jquery, python3-sqlparse, + python3-tz, Suggests: bpython3, geoip-database-contrib, @@ -69,7 +70,6 @@ Suggests: python3-mysqldb, python3-pil, python3-psycopg2, - python3-pytz, python3-selenium, python3-sqlite, python3-yaml, diff --git a/debian/patches/postgresql-18-skip-not-null-constraints.patch b/debian/patches/postgresql-18-skip-not-null-constraints.patch deleted file mode 100644 index 1c2ea723..00000000 --- a/debian/patches/postgresql-18-skip-not-null-constraints.patch +++ /dev/null @@ -1,30 +0,0 @@ -From: Mariusz Felisiak -Date: Sun, 28 Sep 2025 16:11:23 +0200 -Subject: Skipped NOT NULL constraints on PostgreSQL 18+. - -PostgreSQL 18+ stores column "NOT NULL" specifications in pg_constraint. - -https://www.postgresql.org/docs/release/18.0/ - -Origin: upstream, https://github.com/django/django/pull/19910 -Bug-Debian: https://bugs.debian.org/1117647 -Last-Update: 2025-10-22 ---- - django/db/backends/postgresql/introspection.py | 4 +++- - 1 file changed, 3 insertions(+), 1 deletion(-) - -diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py -index 69bc8712bdb4..fb876df215c2 100644 ---- a/django/db/backends/postgresql/introspection.py -+++ b/django/db/backends/postgresql/introspection.py -@@ -206,7 +206,9 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): - cl.reloptions - FROM pg_constraint AS c - JOIN pg_class AS cl ON c.conrelid = cl.oid -- WHERE cl.relname = %s AND pg_catalog.pg_table_is_visible(cl.oid) -+ WHERE cl.relname = %s -+ AND pg_catalog.pg_table_is_visible(cl.oid) -+ AND c.contype != 'n' - """, - [table_name], - ) diff --git a/debian/patches/py314-copy-BaseContext.patch b/debian/patches/py314-copy-BaseContext.patch deleted file mode 100644 index dff289b2..00000000 --- a/debian/patches/py314-copy-BaseContext.patch +++ /dev/null @@ -1,54 +0,0 @@ -From: Mariusz Felisiak -Date: Sun, 17 Nov 2024 16:07:23 +0100 -Subject: Refs #35844 -- Fixed copying BaseContext and its subclasses on - Python 3.14+. - -super objects are copyable on Python 3.14+: - -https://github.com/python/cpython/commit/5ca4e34bc1aab8321911aac6d5b2b9e75ff764d8 - -and can no longer be used in BaseContext.__copy__(). - -Origin: backport, https://github.com/django/django/pull/18824 -Bug-Debian: https://bugs.debian.org/1122185 -Last-Update: 2025-12-17 ---- - django/template/context.py | 4 +++- - tests/template_tests/test_context.py | 8 ++++++++ - 2 files changed, 11 insertions(+), 1 deletion(-) - -diff --git a/django/template/context.py b/django/template/context.py -index ccf0b43..5c38e40 100644 ---- a/django/template/context.py -+++ b/django/template/context.py -@@ -35,7 +35,9 @@ class BaseContext: - self.dicts.append(value) - - def __copy__(self): -- duplicate = copy(super()) -+ duplicate = BaseContext() -+ duplicate.__class__ = self.__class__ -+ duplicate.__dict__ = copy(self.__dict__) - duplicate.dicts = self.dicts[:] - return duplicate - -diff --git a/tests/template_tests/test_context.py b/tests/template_tests/test_context.py -index 4feb9e5..36de455 100644 ---- a/tests/template_tests/test_context.py -+++ b/tests/template_tests/test_context.py -@@ -1,3 +1,4 @@ -+from copy import copy - from unittest import mock - - from django.http import HttpRequest -@@ -276,3 +277,10 @@ class RequestContextTests(SimpleTestCase): - context = RequestContext(request, {}) - context["foo"] = "foo" - self.assertEqual(template.render(context), "foo") -+ -+ def test_context_copyable(self): -+ request_context = RequestContext(HttpRequest()) -+ request_context_copy = copy(request_context) -+ self.assertIsInstance(request_context_copy, RequestContext) -+ self.assertEqual(request_context_copy.dicts, request_context.dicts) -+ self.assertIsNot(request_context_copy.dicts, request_context.dicts) diff --git a/debian/patches/py314-test-prefetch-related-queryset.patch b/debian/patches/py314-test-prefetch-related-queryset.patch deleted file mode 100644 index 255b3e6d..00000000 --- a/debian/patches/py314-test-prefetch-related-queryset.patch +++ /dev/null @@ -1,52 +0,0 @@ -From: Mariusz Felisiak -Date: Fri, 20 Dec 2024 08:43:14 +0100 -Subject: Refs #35844 -- Fixed - OtherModelFormTests.test_prefetch_related_queryset() test on Python 3.14+. - -https://github.com/python/cpython/commit/5a23994a3dbee43a0b08f5920032f60f38b63071 - -Origin: backport, https://github.com/django/django/pull/18953.patch -Bug-Debian: https://bugs.debian.org/1122185 -Last-Update: 2025-12-17 ---- - django/utils/version.py | 1 + - tests/model_forms/tests.py | 7 ++++++- - 2 files changed, 7 insertions(+), 1 deletion(-) - -diff --git a/django/utils/version.py b/django/utils/version.py -index 71ec70b..0144a05 100644 ---- a/django/utils/version.py -+++ b/django/utils/version.py -@@ -18,6 +18,7 @@ PY310 = sys.version_info >= (3, 10) - PY311 = sys.version_info >= (3, 11) - PY312 = sys.version_info >= (3, 12) - PY313 = sys.version_info >= (3, 13) -+PY314 = sys.version_info >= (3, 14) - - - def get_version(version=None): -diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py -index 8268032..0254393 100644 ---- a/tests/model_forms/tests.py -+++ b/tests/model_forms/tests.py -@@ -23,6 +23,7 @@ from django.forms.models import ( - from django.template import Context, Template - from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature - from django.test.utils import isolate_apps -+from django.utils.version import PY314 - - from .models import ( - Article, -@@ -2947,7 +2948,11 @@ class OtherModelFormTests(TestCase): - return ", ".join(c.name for c in obj.colours.all()) - - field = ColorModelChoiceField(ColourfulItem.objects.prefetch_related("colours")) -- with self.assertNumQueries(3): # would be 4 if prefetch is ignored -+ # CPython < 3.14 calls ModelChoiceField.__len__() when coercing to -+ # tuple. Python 3.14+ doesn't call __len__() and so .count() -+ # isn't called on the QuerySet. The following would trigger an extra -+ # query if prefetch were ignored. -+ with self.assertNumQueries(2 if PY314 else 3): - self.assertEqual( - tuple(field.choices), - ( diff --git a/debian/patches/py314-test-runner-parallel.patch b/debian/patches/py314-test-runner-parallel.patch deleted file mode 100644 index 9e996ff7..00000000 --- a/debian/patches/py314-test-runner-parallel.patch +++ /dev/null @@ -1,73 +0,0 @@ -From: Mariusz Felisiak -Date: Wed, 16 Oct 2024 19:37:20 +0200 -Subject: Refs #35844 -- Fixed tests for test --parallel option on Python - 3.14+. - -"forkserver" is the new default on POSIX systems, and Django doesn't -support parallel tests with "forkserver": - -https://github.com/python/cpython/commit/b65f2cdfa77d8d12c213aec663ddaaa30d75a4b2 - -Origin: upstream, https://github.com/django/django/pull/18685 -Bug-Debian: https://bugs.debian.org/1122185 -Last-Update: 2025-12-17 ---- - tests/test_runner/test_discover_runner.py | 1 + - tests/test_runner/tests.py | 5 +++++ - 2 files changed, 6 insertions(+) - -diff --git a/tests/test_runner/test_discover_runner.py b/tests/test_runner/test_discover_runner.py -index bca9037..5c94695 100644 ---- a/tests/test_runner/test_discover_runner.py -+++ b/tests/test_runner/test_discover_runner.py -@@ -44,6 +44,7 @@ def change_loader_patterns(patterns): - @mock.patch.dict(os.environ, {}, clear=True) - @mock.patch.object(multiprocessing, "cpu_count", return_value=12) - # Python 3.8 on macOS defaults to 'spawn' mode. -+# Python 3.14 on POSIX systems defaults to 'forkserver' mode. - @mock.patch.object(multiprocessing, "get_start_method", return_value="fork") - class DiscoverRunnerParallelArgumentTests(SimpleTestCase): - def get_parser(self): -diff --git a/tests/test_runner/tests.py b/tests/test_runner/tests.py -index c8b40ea..03444f1 100644 ---- a/tests/test_runner/tests.py -+++ b/tests/test_runner/tests.py -@@ -481,6 +481,7 @@ class ManageCommandTests(unittest.TestCase): - @mock.patch.dict(os.environ, {}, clear=True) - @mock.patch.object(multiprocessing, "cpu_count", return_value=12) - class ManageCommandParallelTests(SimpleTestCase): -+ @mock.patch.object(multiprocessing, "get_start_method", return_value="fork") - def test_parallel_default(self, *mocked_objects): - with captured_stderr() as stderr: - call_command( -@@ -490,6 +491,7 @@ class ManageCommandParallelTests(SimpleTestCase): - ) - self.assertIn("parallel=12", stderr.getvalue()) - -+ @mock.patch.object(multiprocessing, "get_start_method", return_value="fork") - def test_parallel_auto(self, *mocked_objects): - with captured_stderr() as stderr: - call_command( -@@ -525,12 +527,14 @@ class ManageCommandParallelTests(SimpleTestCase): - self.assertEqual(stderr.getvalue(), "") - - @mock.patch.dict(os.environ, {"DJANGO_TEST_PROCESSES": "7"}) -+ @mock.patch.object(multiprocessing, "get_start_method", return_value="fork") - def test_no_parallel_django_test_processes_env(self, *mocked_objects): - with captured_stderr() as stderr: - call_command("test", testrunner="test_runner.tests.MockTestRunner") - self.assertEqual(stderr.getvalue(), "") - - @mock.patch.dict(os.environ, {"DJANGO_TEST_PROCESSES": "invalid"}) -+ @mock.patch.object(multiprocessing, "get_start_method", return_value="fork") - def test_django_test_processes_env_non_int(self, *mocked_objects): - with self.assertRaises(ValueError): - call_command( -@@ -540,6 +544,7 @@ class ManageCommandParallelTests(SimpleTestCase): - ) - - @mock.patch.dict(os.environ, {"DJANGO_TEST_PROCESSES": "7"}) -+ @mock.patch.object(multiprocessing, "get_start_method", return_value="fork") - def test_django_test_processes_parallel_default(self, *mocked_objects): - for parallel in ["--parallel", "--parallel=auto"]: - with self.subTest(parallel=parallel): diff --git a/debian/patches/series b/debian/patches/series index e55c43a0..0e8a07b3 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -3,8 +3,3 @@ 0004-Use-locally-installed-documentation-sources.patch 0004-Set-the-default-shebang-to-new-projects-to-use-Pytho.patch py313-test-help-default-options-with-custom-arguments.patch -postgresql-18-skip-not-null-constraints.patch -py314-test-runner-parallel.patch -py314-copy-BaseContext.patch -py314-test-prefetch-related-queryset.patch -test-strip-tags-incomplete-entities.patch diff --git a/debian/patches/test-strip-tags-incomplete-entities.patch b/debian/patches/test-strip-tags-incomplete-entities.patch deleted file mode 100644 index 91687a3c..00000000 --- a/debian/patches/test-strip-tags-incomplete-entities.patch +++ /dev/null @@ -1,77 +0,0 @@ -From: Jacob Walls -Date: Thu, 11 Dec 2025 08:44:19 -0500 -Subject: Refs #36499 -- Adjusted test_strip_tags following Python behavior - change for incomplete entities. - -Origin: backport, https://github.com/django/django/pull/20390 -Bug-Debian: https://bugs.debian.org/1122185 -Last-Update: 2025-12-17 ---- - tests/utils_tests/test_html.py | 29 ++++++++++++++++++++++------- - 1 file changed, 22 insertions(+), 7 deletions(-) - -diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py -index f755b8c..f027940 100644 ---- a/tests/utils_tests/test_html.py -+++ b/tests/utils_tests/test_html.py -@@ -1,3 +1,4 @@ -+import math - import os - import sys - from datetime import datetime -@@ -92,7 +93,7 @@ class TestUtilsHtml(SimpleTestCase): - # old and new results. The check below is temporary until all supported - # Python versions and CI workers include the fix. See: - # https://github.com/python/cpython/commit/6eb6c5db -- min_fixed = { -+ min_fixed_security = { - (3, 14): (3, 14), - (3, 13): (3, 13, 6), - (3, 12): (3, 12, 12), -@@ -100,9 +101,20 @@ class TestUtilsHtml(SimpleTestCase): - (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] -+ # Similarly, there was a fix for terminating incomplete entities. See: -+ # https://github.com/python/cpython/commit/95296a9d -+ min_fixed_incomplete_entities = { -+ (3, 14): (3, 14, 1), -+ (3, 13): (3, 13, 10), -+ (3, 12): (3, 12, math.inf), # not fixed in 3.12. -+ } -+ major_version = sys.version_info[:2] -+ htmlparser_fixed_security = sys.version_info >= min_fixed_security.get( -+ major_version, major_version -+ ) -+ htmlparser_fixed_incomplete_entities = ( -+ sys.version_info -+ >= min_fixed_incomplete_entities.get(major_version, major_version) - ) - items = ( - ( -@@ -130,16 +142,19 @@ class TestUtilsHtml(SimpleTestCase): - # https://bugs.python.org/issue20288 - ("&gotcha&#;<>", "&gotcha&#;<>"), - ("ript>test</script>", "ript>test"), -- ("&h", "alert()h"), -+ ( -+ "&h", -+ "alert()&h;" if htmlparser_fixed_incomplete_entities else "alert()h", -+ ), - ( - ">" if htmlparser_fixed else ">" if htmlparser_fixed_security else ">br>br>br>X", "XX"), - ("<" * 50 + "a>" * 50, ""), - ( - ">" + "" if htmlparser_fixed else ">" + "" if htmlparser_fixed_security else ">" + "` +allowed remote attackers to enumerate users via a timing attack. + +This issue has severity "low" according to the :ref:`Django security policy +`. + +CVE-2025-14550: Potential denial-of-service vulnerability via repeated headers when using ASGI +============================================================================================== + +When receiving duplicates of a single header, ``ASGIRequest`` allowed a remote +attacker to cause a potential denial-of-service via a specifically created +request with multiple duplicate headers. The vulnerability resulted from +repeated string concatenation while combining repeated headers, which +produced super-linear computation resulting in service degradation or outage. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-1207: Potential SQL injection via raster lookups on PostGIS +==================================================================== + +:ref:`Raster lookups ` on GIS fields (only implemented +on PostGIS) were subject to SQL injection if untrusted data was used as a band +index. + +As a reminder, all untrusted user input should be validated before use. + +This issue has severity "high" according to the :ref:`Django security policy +`. +Django 4.2.28 fixes two security issues with severity "moderate", three +security issues with severity "moderate", and one security issue with severity +"low" in 4.2.27. + +CVE-2026-1285: Potential denial-of-service vulnerability in ``django.utils.text.Truncator`` HTML methods +======================================================================================================== + +``django.utils.text.Truncator.chars()`` and ``Truncator.words()`` methods (with +``html=True``) and the :tfilter:`truncatechars_html` and +:tfilter:`truncatewords_html` template filters were subject to a potential +denial-of-service attack via certain inputs with a large number of unmatched +HTML end tags, which could cause quadratic time complexity during HTML parsing. + +This issue has severity "moderate" according to the Django security policy. +This issue has severity "moderate" according to the :ref:`Django security +policy `. + +CVE-2026-1287: Potential SQL injection in column aliases via control characters +=============================================================================== + +:class:`.FilteredRelation` was subject to SQL injection in column aliases via +control characters, using a suitably crafted dictionary, with dictionary +expansion, as the ``**kwargs`` passed to :meth:`.QuerySet.annotate`, +:meth:`~.QuerySet.aggregate`, :meth:`~.QuerySet.extra`, +:meth:`~.QuerySet.values`, :meth:`~.QuerySet.values_list`, and +:meth:`~.QuerySet.alias`. + +This issue has severity "high" according to the :ref:`Django security policy +`. + +CVE-2026-1312: Potential SQL injection via ``QuerySet.order_by`` and ``FilteredRelation`` +========================================================================================= + +:meth:`.QuerySet.order_by` was subject to SQL injection in column aliases +containing periods when the same alias was, using a suitably crafted +dictionary, with dictionary expansion, used in :class:`.FilteredRelation`. + +This issue has severity "high" according to the :ref:`Django security policy +`. diff --git a/docs/releases/index.txt b/docs/releases/index.txt index f5a6770b..e71fc6f5 100644 --- a/docs/releases/index.txt +++ b/docs/releases/index.txt @@ -26,6 +26,7 @@ versions of the documentation contain the release notes for any later releases. .. toctree:: :maxdepth: 1 + 4.2.28 4.2.27 4.2.26 4.2.25 diff --git a/docs/releases/security.txt b/docs/releases/security.txt index e5b9878a..b7bd09f1 100644 --- a/docs/releases/security.txt +++ b/docs/releases/security.txt @@ -36,6 +36,30 @@ Issues under Django's security process All security issues have been handled under versions of Django's security process. These are listed below. +December 2, 2025 - :cve:`2025-13372` +------------------------------------ + +Potential SQL injection in ``FilteredRelation`` column aliases on PostgreSQL. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <56aea00c3c5e1aacf4ed05f8ee06c2e78f02cea0>` +* Django 5.2 :commit:`(patch) <479415ce5249bcdebeb6570c72df2a87f45a7bbf>` +* Django 5.1 :commit:`(patch) <9c6a5bde24240382807d13bc3748d08444709355>` +* Django 4.2 :commit:`(patch) ` + +December 2, 2025 - :cve:`2025-64460` +------------------------------------ + +Potential denial-of-service vulnerability in XML serializer text extraction. +`Full description +`__ + +* Django 6.0 :commit:`(patch) <1dbd07a608e495a0c229edaaf84d58d8976313b5>` +* Django 5.2 :commit:`(patch) <99e7d22f55497278d0bcb2e15e72ef532e62a31d>` +* Django 5.1 :commit:`(patch) <0db9ea4669312f1f4973e09f4bca06ab9c1ec74b>` +* Django 4.2 :commit:`(patch) <4d2b8803bebcdefd2b76e9e8fc528d5fddea93f0>` + November 5, 2025 - :cve:`2025-64458` ------------------------------------ diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py index 277c0507..abd43fc2 100644 --- a/tests/aggregation/tests.py +++ b/tests/aggregation/tests.py @@ -2,6 +2,7 @@ import math import re from decimal import Decimal +from itertools import chain from django.core.exceptions import FieldError from django.db import connection @@ -2088,13 +2089,18 @@ def test_exists_none_with_aggregate(self): self.assertEqual(len(qs), 6) def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "aggregation_author"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." - ) - with self.assertRaisesMessage(ValueError, msg): - Author.objects.aggregate(**{crafted_alias: Avg("age")}) + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." + ) + for crafted_alias in [ + """injected_name" from "aggregation_author"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Author.objects.aggregate(**{crafted_alias: Avg("age")}) def test_exists_extra_where_with_aggregate(self): qs = Book.objects.annotate( diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py index d876e3a6..93569179 100644 --- a/tests/annotations/tests.py +++ b/tests/annotations/tests.py @@ -1,5 +1,6 @@ import datetime from decimal import Decimal +from itertools import chain from django.core.exceptions import FieldDoesNotExist, FieldError from django.db import connection @@ -1115,22 +1116,32 @@ def test_annotation_aggregate_with_m2o(self): ) def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." - ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.annotate(**{crafted_alias: Value(1)}) + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." + ) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.annotate(**{crafted_alias: Value(1)}) def test_alias_filtered_relation_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." - ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." + ) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.annotate(**{crafted_alias: FilteredRelation("author")}) def test_alias_forbidden_chars(self): tests = [ @@ -1148,10 +1159,11 @@ def test_alias_forbidden_chars(self): "alias[", "alias]", "ali#as", + "ali\0as", ] msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) for crafted_alias in tests: with self.subTest(crafted_alias): @@ -1428,22 +1440,32 @@ def test_values_alias(self): getattr(qs, operation)("rating_alias") def test_alias_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." - ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.alias(**{crafted_alias: Value(1)}) + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." + ) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.alias(**{crafted_alias: Value(1)}) def test_alias_filtered_relation_sql_injection(self): - crafted_alias = """injected_name" from "annotations_book"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." - ) - with self.assertRaisesMessage(ValueError, msg): - Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." + ) + for crafted_alias in [ + """injected_name" from "annotations_book"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Book.objects.alias(**{crafted_alias: FilteredRelation("authors")}) def test_alias_filtered_relation_sql_injection_dollar_sign(self): qs = Book.objects.alias( diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index f2e293d8..9395c862 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -7,6 +7,7 @@ from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler from django.core.asgi import get_asgi_application +from django.core.handlers.asgi import ASGIRequest from django.core.signals import request_finished, request_started from django.db import close_old_connections from django.test import ( @@ -193,6 +194,32 @@ async def test_post_body(self): self.assertEqual(response_body["type"], "http.response.body") self.assertEqual(response_body["body"], b"Echo!") + async def test_meta_not_modified_with_repeat_headers(self): + scope = self.async_request_factory._base_scope(path="/", http_version="2.0") + scope["headers"] = [(b"foo", b"bar")] * 200_000 + + setitem_count = 0 + + class InstrumentedDict(dict): + def __setitem__(self, *args, **kwargs): + nonlocal setitem_count + setitem_count += 1 + super().__setitem__(*args, **kwargs) + + class InstrumentedASGIRequest(ASGIRequest): + @property + def META(self): + return self._meta + + @META.setter + def META(self, value): + self._meta = InstrumentedDict(**value) + + request = InstrumentedASGIRequest(scope, None) + + self.assertEqual(len(request.headers["foo"].split(",")), 200_000) + self.assertLessEqual(setitem_count, 100) + async def test_untouched_request_body_gets_closed(self): application = get_asgi_application() scope = self.async_request_factory._base_scope(method="POST", path="/post/") diff --git a/tests/auth_tests/test_handlers.py b/tests/auth_tests/test_handlers.py index a6b53a9e..857c5325 100644 --- a/tests/auth_tests/test_handlers.py +++ b/tests/auth_tests/test_handlers.py @@ -1,4 +1,7 @@ +from unittest import mock + from django.contrib.auth.handlers.modwsgi import check_password, groups_for_user +from django.contrib.auth.hashers import get_hasher from django.contrib.auth.models import Group, User from django.test import TransactionTestCase, override_settings @@ -73,3 +76,26 @@ def test_groups_for_user(self): self.assertEqual(groups_for_user({}, "test"), [b"test_group"]) self.assertEqual(groups_for_user({}, "test1"), []) + + def test_check_password_fake_runtime(self): + """ + Hasher is run once regardless of whether the user exists. Refs #20760. + """ + User.objects.create_user("test", "test@example.com", "test") + User.objects.create_user("inactive", "test@nono.com", "test", is_active=False) + User.objects.create_user("unusable", "test@nono.com") + + hasher = get_hasher() + + for username, password in [ + ("test", "test"), + ("test", "wrong"), + ("inactive", "test"), + ("inactive", "wrong"), + ("unusable", "test"), + ("doesnotexist", "test"), + ]: + with self.subTest(username=username, password=password): + with mock.patch.object(hasher, "encode") as mock_make_password: + check_password({}, username, password) + mock_make_password.assert_called_once() diff --git a/tests/expressions/test_queryset_values.py b/tests/expressions/test_queryset_values.py index 080ee061..afd8a511 100644 --- a/tests/expressions/test_queryset_values.py +++ b/tests/expressions/test_queryset_values.py @@ -1,3 +1,5 @@ +from itertools import chain + from django.db.models import F, Sum from django.test import TestCase, skipUnlessDBFeature @@ -35,26 +37,36 @@ def test_values_expression(self): ) def test_values_expression_alias_sql_injection(self): - crafted_alias = """injected_name" from "expressions_company"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - Company.objects.values(**{crafted_alias: F("ceo__salary")}) + for crafted_alias in [ + """injected_name" from "expressions_company"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Company.objects.values(**{crafted_alias: F("ceo__salary")}) @skipUnlessDBFeature("supports_json_field") def test_values_expression_alias_sql_injection_json_field(self): - crafted_alias = """injected_name" from "expressions_company"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." ) - with self.assertRaisesMessage(ValueError, msg): - JSONFieldModel.objects.values(f"data__{crafted_alias}") + for crafted_alias in [ + """injected_name" from "expressions_company"; --""", + # Control characters. + *(chr(c) for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + JSONFieldModel.objects.values(f"data__{crafted_alias}") - with self.assertRaisesMessage(ValueError, msg): - JSONFieldModel.objects.values_list(f"data__{crafted_alias}") + with self.assertRaisesMessage(ValueError, msg): + JSONFieldModel.objects.values_list(f"data__{crafted_alias}") def test_values_expression_group_by(self): # values() applies annotate() first, so values selected are grouped by diff --git a/tests/filtered_relation/tests.py b/tests/filtered_relation/tests.py index 0fce8b09..4847d1f0 100644 --- a/tests/filtered_relation/tests.py +++ b/tests/filtered_relation/tests.py @@ -211,6 +211,19 @@ def test_internal_queryset_alias_mapping(self): str(queryset.query), ) + def test_period_forbidden(self): + msg = ( + "FilteredRelation doesn't support aliases with periods (got 'book.alice')." + ) + with self.assertRaisesMessage(ValueError, msg): + Author.objects.annotate( + **{ + "book.alice": FilteredRelation( + "book", condition=Q(book__title__iexact="poem by alice") + ) + } + ) + def test_multiple(self): qs = ( Author.objects.annotate( diff --git a/tests/gis_tests/rasterapp/test_rasterfield.py b/tests/gis_tests/rasterapp/test_rasterfield.py index 3f2ce770..89c4ec48 100644 --- a/tests/gis_tests/rasterapp/test_rasterfield.py +++ b/tests/gis_tests/rasterapp/test_rasterfield.py @@ -2,7 +2,11 @@ from django.contrib.gis.db.models.fields import BaseSpatialField from django.contrib.gis.db.models.functions import Distance -from django.contrib.gis.db.models.lookups import DistanceLookupBase, GISLookup +from django.contrib.gis.db.models.lookups import ( + DistanceLookupBase, + GISLookup, + RasterBandTransform, +) from django.contrib.gis.gdal import GDALRaster from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.measure import D @@ -356,6 +360,47 @@ def test_lookup_input_band_not_allowed(self): with self.assertRaisesMessage(ValueError, msg): qs.count() + def test_lookup_invalid_band_rhs(self): + rast = GDALRaster(json.loads(JSON_RASTER)) + qs = RasterModel.objects.filter(rast__contains=(rast, "evil")) + msg = "Band index must be an integer, but got 'str'." + with self.assertRaisesMessage(TypeError, msg): + qs.count() + + def test_lookup_invalid_band_lhs(self): + """ + Typical left-hand side usage is protected against non-integers, but for + defense-in-depth purposes, construct custom lookups that evade the + `int()` and `+ 1` checks in the lookups shipped by django.contrib.gis. + """ + + # Evade the int() call in RasterField.get_transform(). + class MyRasterBandTransform(RasterBandTransform): + band_index = "evil" + + def process_band_indices(self, *args, **kwargs): + self.band_lhs = self.lhs.band_index + self.band_rhs, *self.rhs_params = self.rhs_params + + # Evade the `+ 1` call in BaseSpatialField.process_band_indices(). + ContainsLookup = RasterModel._meta.get_field("rast").get_lookup("contains") + + class MyContainsLookup(ContainsLookup): + def process_band_indices(self, *args, **kwargs): + self.band_lhs = self.lhs.band_index + self.band_rhs, *self.rhs_params = self.rhs_params + + RasterField = RasterModel._meta.get_field("rast") + RasterField.register_lookup(MyContainsLookup, "contains") + self.addCleanup(RasterField.register_lookup, ContainsLookup, "contains") + + qs = RasterModel.objects.annotate( + transformed=MyRasterBandTransform("rast") + ).filter(transformed__contains=(F("transformed"), 1)) + msg = "Band index must be an integer, but got 'str'." + with self.assertRaisesMessage(TypeError, msg): + list(qs) + def test_isvalid_lookup_with_raster_error(self): qs = RasterModel.objects.filter(rast__isvalid=True) msg = ( diff --git a/tests/ordering/tests.py b/tests/ordering/tests.py index b29404ed..30da0258 100644 --- a/tests/ordering/tests.py +++ b/tests/ordering/tests.py @@ -7,6 +7,7 @@ Count, DateTimeField, F, + FilteredRelation, Max, OrderBy, OuterRef, @@ -392,6 +393,35 @@ def test_extra_ordering_with_table_name(self): attrgetter("headline"), ) + def test_alias_with_period_shadows_table_name(self): + """ + Aliases with periods are not confused for table names from extra(). + """ + Article.objects.update(author=self.author_2) + Article.objects.create( + headline="Backdated", pub_date=datetime(1900, 1, 1), author=self.author_1 + ) + crafted = "ordering_article.pub_date" + + qs = Article.objects.annotate(**{crafted: F("author")}).order_by("-" + crafted) + self.assertNotEqual(qs[0].headline, "Backdated") + + relation = FilteredRelation("author") + msg = ( + "FilteredRelation doesn't support aliases with periods " + "(got 'ordering_article.pub_date')." + ) + with self.assertRaisesMessage(ValueError, msg): + qs2 = Article.objects.annotate(**{crafted: relation}).order_by(crafted) + # Before, unlike F(), which causes ordering expressions to be + # replaced by ordinals like n in ORDER BY n, these were ordered by + # pub_date instead of author. + # The Article model orders by -pk, so sorting on author will place + # first any article by author2 instead of the backdated one. + # This assertion is reachable if FilteredRelation.__init__() starts + # supporting periods in aliases in the future. + self.assertNotEqual(qs2[0].headline, "Backdated") + def test_order_by_pk(self): """ 'pk' works as an ordering option in Meta. diff --git a/tests/queries/tests.py b/tests/queries/tests.py index 2290ea29..d0af410e 100644 --- a/tests/queries/tests.py +++ b/tests/queries/tests.py @@ -2,6 +2,7 @@ import pickle import sys import unittest +from itertools import chain from operator import attrgetter from threading import Lock @@ -1941,13 +1942,18 @@ def test_extra_select_literal_percent_s(self): ) def test_extra_select_alias_sql_injection(self): - crafted_alias = """injected_name" from "queries_note"; --""" msg = ( - "Column aliases cannot contain whitespace characters, hashes, quotation " - "marks, semicolons, or SQL comments." - ) - with self.assertRaisesMessage(ValueError, msg): - Note.objects.extra(select={crafted_alias: "1"}) + "Column aliases cannot contain whitespace characters, hashes, " + "control characters, quotation marks, semicolons, or SQL comments." + ) + for crafted_alias in [ + """injected_name" from "queries_note"; --""", + # Control characters. + *(f"name{chr(c)}" for c in chain(range(32), range(0x7F, 0xA0))), + ]: + with self.subTest(crafted_alias): + with self.assertRaisesMessage(ValueError, msg): + Note.objects.extra(select={crafted_alias: "1"}) def test_queryset_reuse(self): # Using querysets doesn't mutate aliases. diff --git a/tests/runtests.py b/tests/runtests.py index b6789883..b3150ab6 100755 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -65,16 +65,6 @@ TEMPLATE_DIR = os.path.join(RUNTESTS_DIR, "templates") -# Create a specific subdirectory for the duration of the test suite. -TMPDIR = tempfile.mkdtemp(prefix="django_") -# Set the TMPDIR environment variable in addition to tempfile.tempdir -# so that children processes inherit it. -tempfile.tempdir = os.environ["TMPDIR"] = TMPDIR - -# Removing the temporary TMPDIR. -atexit.register(shutil.rmtree, TMPDIR) - - # This is a dict mapping RUNTESTS_DIR subdirectory to subdirectories of that # directory to skip when searching for test modules. SUBDIRS_TO_SKIP = { @@ -197,6 +187,7 @@ def _module_match_label(module_name, label): def setup_collect_tests(start_at, start_after, test_labels=None): + TMPDIR = os.environ["TMPDIR"] state = { "INSTALLED_APPS": settings.INSTALLED_APPS, "ROOT_URLCONF": getattr(settings, "ROOT_URLCONF", ""), @@ -334,13 +325,6 @@ def no_available_apps(self): def teardown_run_tests(state): teardown_collect_tests(state) - # Discard the multiprocessing.util finalizer that tries to remove a - # temporary directory that's already removed by this script's - # atexit.register(shutil.rmtree, TMPDIR) handler. Prevents - # FileNotFoundError at the end of a test run (#27890). - from multiprocessing.util import _finalizer_registry - - _finalizer_registry.pop((-100, 0), None) del os.environ["RUNNING_DJANGOS_TEST_SUITE"] @@ -539,6 +523,14 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after): if __name__ == "__main__": + # Create a specific subdirectory for the duration of the test suite. + TMPDIR = tempfile.mkdtemp(prefix="django_") + # Set the TMPDIR environment variable in addition to tempfile.tempdir + # so that children processes inherit it. + tempfile.tempdir = os.environ["TMPDIR"] = TMPDIR + # Remove the temporary TMPDIR. + atexit.register(shutil.rmtree, TMPDIR) + parser = argparse.ArgumentParser(description="Run the Django test suite.") parser.add_argument( "modules", diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py index f755b8ce..a5acc582 100644 --- a/tests/utils_tests/test_html.py +++ b/tests/utils_tests/test_html.py @@ -1,3 +1,4 @@ +import math import os import sys from datetime import datetime @@ -92,17 +93,35 @@ def test_strip_tags(self): # old and new results. The check below is temporary until all supported # Python versions and CI workers include the fix. See: # https://github.com/python/cpython/commit/6eb6c5db - min_fixed = { + min_fixed_security = { (3, 14): (3, 14), (3, 13): (3, 13, 6), (3, 12): (3, 12, 12), (3, 11): (3, 11, 14), (3, 10): (3, 10, 19), (3, 9): (3, 9, 24), + # Not fixed in 3.8. + (3, 8): (3, 8, math.inf), } - py_version = sys.version_info[:2] - htmlparser_fixed = ( - py_version in min_fixed and sys.version_info >= min_fixed[py_version] + # Similarly, there was a fix for terminating incomplete entities. See: + # https://github.com/python/cpython/commit/95296a9d + min_fixed_incomplete_entities = { + (3, 14): (3, 14, 1), + (3, 13): (3, 13, 10), + # Not fixed in the following versions. + (3, 12): (3, 12, math.inf), + (3, 11): (3, 11, math.inf), + (3, 10): (3, 10, math.inf), + (3, 9): (3, 9, math.inf), + (3, 8): (3, 8, math.inf), + } + major_version = sys.version_info[:2] + htmlparser_fixed_security = sys.version_info >= min_fixed_security.get( + major_version, major_version + ) + htmlparser_fixed_incomplete_entities = ( + sys.version_info + >= min_fixed_incomplete_entities.get(major_version, major_version) ) items = ( ( @@ -130,16 +149,19 @@ def test_strip_tags(self): # https://bugs.python.org/issue20288 ("&gotcha&#;<>", "&gotcha&#;<>"), ("ript>test</script>", "ript>test"), - ("&h", "alert()h"), + ( + "&h", + "alert()&h;" if htmlparser_fixed_incomplete_entities else "alert()h", + ), ( ">" if htmlparser_fixed else ">" if htmlparser_fixed_security else ">br>br>br>X", "XX"), ("<" * 50 + "a>" * 50, ""), ( ">" + "" if htmlparser_fixed else ">" + "" if htmlparser_fixed_security else ">" + ", the doesn't match , so all tags remain + # in the stack and are properly closed at truncation. + truncator = text.Truncator("XXXX") + self.assertEqual( + truncator.chars(2, html=True, truncate=""), + "XX", + ) + @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000) def test_truncate_chars_html_size_limit(self): max_len = text.Truncator.MAX_LENGTH_HTML