Version in base suite: 3.2.19-1+deb12u1 Version in overlay suite: 3.2.19-1+deb12u2 Base version: python-django_3.2.19-1+deb12u2 Target version: python-django_3.2.25-0+deb12u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-django/python-django_3.2.19-1+deb12u2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-django/python-django_3.2.25-0+deb12u1.dsc Django.egg-info/PKG-INFO | 2 Django.egg-info/SOURCES.txt | 6 PKG-INFO | 2 debian/changelog | 183 ++++++++++++++-- debian/patches/0013-fix-url-validator.patch | 8 debian/patches/0014-CVE-2023-36053.patch | 242 --------------------- debian/patches/0014-CVE-2024-39329.patch | 83 +++++++ debian/patches/0015-CVE-2024-39329.patch | 80 ------- debian/patches/0015-CVE-2024-39330.patch | 149 +++++++++++++ debian/patches/0016-CVE-2024-39330.patch | 145 ------------ debian/patches/0016-CVE-2024-39614.patch | 129 +++++++++++ debian/patches/0017-CVE-2024-39614-1.patch | 127 ----------- debian/patches/0017-CVE-2024-41989.patch | 76 ++++++ debian/patches/0018-CVE-2024-39614-2.patch | 92 -------- debian/patches/0018-CVE-2024-41991.patch | 113 ++++++++++ debian/patches/0019-CVE-2024-41989.patch | 67 ----- debian/patches/0019-CVE-2024-42005.patch | 74 ++++++ debian/patches/0020-CVE-2024-41991.patch | 108 --------- debian/patches/0020-CVE-2024-45231.patch | 111 +++++++++ debian/patches/0021-CVE-2024-42005.patch | 70 ------ debian/patches/0021-CVE-2024-53907.patch | 85 +++++++ debian/patches/0022-CVE-2024-56374.patch | 258 +++++++++++++++++++++++ debian/patches/0023-CVE-2025-13372.patch | 92 ++++++++ debian/patches/0024-CVE-2025-26699.patch | 76 ++++++ debian/patches/0025-CVE-2025-32873.patch | 80 +++++++ debian/patches/0026-CVE-2025-48432.patch | 75 ++++++ debian/patches/0027-CVE-2025-48432-2.patch | 127 +++++++++++ debian/patches/0028-CVE-2025-57833.patch | 66 +++++ debian/patches/0029-CVE-2025-59681.patch | 166 ++++++++++++++ debian/patches/0030-CVE-2025-59682.patch | 66 +++++ debian/patches/0031-CVE-2025-64459.patch | 44 +++ debian/patches/0032-CVE-2025-64460.patch | 102 +++++++++ debian/patches/series | 27 +- django/__init__.py | 2 django/contrib/auth/forms.py | 10 django/contrib/humanize/templatetags/humanize.py | 15 - django/core/validators.py | 7 django/forms/fields.py | 3 django/utils/encoding.py | 6 django/utils/text.py | 75 ++++++ docs/_ext/djangodocs.py | 17 + docs/ref/forms/fields.txt | 7 docs/ref/templates/builtins.txt | 20 + docs/ref/utils.txt | 2 docs/ref/validators.txt | 25 ++ docs/releases/3.2.20.txt | 14 + docs/releases/3.2.21.txt | 14 + docs/releases/3.2.22.txt | 25 ++ docs/releases/3.2.23.txt | 19 + docs/releases/3.2.24.txt | 13 + docs/releases/3.2.25.txt | 22 + docs/releases/index.txt | 6 docs/releases/security.txt | 66 +++++ tests/auth_tests/test_forms.py | 8 tests/forms_tests/field_tests/test_emailfield.py | 5 tests/forms_tests/field_tests/test_filefield.py | 9 tests/forms_tests/tests/test_forms.py | 19 + tests/humanize_tests/tests.py | 152 ++++++++++++- tests/requirements/py3.txt | 2 tests/utils_tests/test_encoding.py | 21 + tests/utils_tests/test_text.py | 61 ++++- tests/validators/tests.py | 11 62 files changed, 2771 insertions(+), 1016 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpj46_f67h/python-django_3.2.19-1+deb12u2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpj46_f67h/python-django_3.2.25-0+deb12u1.dsc: no acceptable signature found diff -Nru python-django-3.2.19/Django.egg-info/PKG-INFO python-django-3.2.25/Django.egg-info/PKG-INFO --- python-django-3.2.19/Django.egg-info/PKG-INFO 2023-05-03 11:59:48.000000000 +0000 +++ python-django-3.2.25/Django.egg-info/PKG-INFO 2024-03-04 07:49:26.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: Django -Version: 3.2.19 +Version: 3.2.25 Summary: A high-level Python Web framework that encourages rapid development and clean, pragmatic design. Home-page: https://www.djangoproject.com/ Author: Django Software Foundation diff -Nru python-django-3.2.19/Django.egg-info/SOURCES.txt python-django-3.2.25/Django.egg-info/SOURCES.txt --- python-django-3.2.19/Django.egg-info/SOURCES.txt 2023-05-03 11:59:48.000000000 +0000 +++ python-django-3.2.25/Django.egg-info/SOURCES.txt 2024-03-04 07:49:26.000000000 +0000 @@ -4039,6 +4039,12 @@ docs/releases/3.2.18.txt docs/releases/3.2.19.txt docs/releases/3.2.2.txt +docs/releases/3.2.20.txt +docs/releases/3.2.21.txt +docs/releases/3.2.22.txt +docs/releases/3.2.23.txt +docs/releases/3.2.24.txt +docs/releases/3.2.25.txt docs/releases/3.2.3.txt docs/releases/3.2.4.txt docs/releases/3.2.5.txt diff -Nru python-django-3.2.19/PKG-INFO python-django-3.2.25/PKG-INFO --- python-django-3.2.19/PKG-INFO 2023-05-03 11:59:49.717188000 +0000 +++ python-django-3.2.25/PKG-INFO 2024-03-04 07:49:27.267542600 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: Django -Version: 3.2.19 +Version: 3.2.25 Summary: A high-level Python Web framework that encourages rapid development and clean, pragmatic design. Home-page: https://www.djangoproject.com/ Author: Django Software Foundation diff -Nru python-django-3.2.19/debian/changelog python-django-3.2.25/debian/changelog --- python-django-3.2.19/debian/changelog 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/changelog 2026-01-27 19:16:59.000000000 +0000 @@ -1,25 +1,168 @@ -python-django (3:3.2.19-1+deb12u2) bookworm; urgency=high +python-django (3:3.2.25-0+deb12u1) bookworm-security; urgency=high - * Rename CVE-2023-36053.patch to 0014-CVE-2023-36053.patch - * Backport upstream fixes in 3:4.2.14-1: - * Closes: #1076069 - * CVE-2024-39329: Standardize timing of verify_password() when - checking unusable passwords. - * CVE-2024-39330: Add extra file name validation in Storage's save - method. - * CVE-2024-39614: Mitigate potential DoS in - get_supported_language_variant. - * The patch for CVE-2024-38875 won't sensibly backport. - * Backport upstream fixes in 3:4.2.15-1: - * Closes: #1078074 - * CVE-2024-41989: Prevent excessive memory consumption in floatformat. - * CVE-2024-41991: Prevente potential ReDoS in django.utils.html.urlize() - and AdminURLFieldWidget. - * CVE-2024-42005: Mitigate QuerySet.values() SQL injection attacks against JSON fields - Backport and tweak the upstream fix series to fit into 3.2. - * The patch for CVE-2024-41990 won't sensibly backport. + * Update to upstream's last 3.2 series release: - -- Steve McIntyre <93sam@debian.org> Wed, 21 Aug 2024 12:08:24 +0100 + - CVE-2023-41164: Potential denial of service vulnerability in + django.utils.encoding.uri_to_iri(). + + This method was subject to potential denial of service attack via certain + inputs with a very large number of Unicode characters. This fix was + released in Django 3.2.21. (Closes: #1051226) + + - CVE-2023-43665: Address a denial-of-service possibility in + django.utils.text.Truncator. + + Following the fix for CVE-2019-14232, the regular expressions used in the + implementation of django.utils.text.Truncator’s chars() and words() + methods (with html=True) were revised and improved. However, these + regular expressions still exhibited linear backtracking complexity, so + when given a very long, potentially malformed HTML input, the evaluation + would still be slow, leading to a potential denial of service + vulnerability. + + The chars() and words() methods are used to implement the + truncatechars_html and truncatewords_html template filters, which were + thus also vulnerable. + + The input processed by Truncator, when operating in HTML mode, has been + limited to the first five million characters in order to avoid potential + performance and memory issues. This fix was included in Django 3.2.22. + + - CVE-2024-24680: Potential denial-of-service in intcomma template filter. + The intcomma template filter was subject to a potential denial-of-service + attack when used with very long strings. This fix was included in Django + 3.2.24. + + - CVE-2024-27351: Fix a potential regular expression denial-of-service + (ReDoS) attack in django.utils.text.Truncator.words. This method + (with html=True) and the truncatewords_html template filter were subject + to a potential regular expression denial-of-service attack via a suitably + crafted string. This is, in part, a follow up to CVE-2019-14232 and + CVE-2023-43665, and was included in Django 3.2.25. + + * Drop debian/patches/CVE-2023-36053.patch now that we include the fix + directly via 3.2.20. + + * CVE-2024-39329: Avoid a username enumeration vulnerability through timing + difference for users with unusable password. The authenticate method of + django.contrib.auth.backends.ModelBackend method allowed remote attackers + to enumerate users via a timing attack involving login requests for users + with unusable passwords. + + * CVE-2024-39330: Address a potential directory-traversal in + django.core.files.storage.Storage.save. Derived classes of this method's + base class which override generate_filename without replicating the file + path validations existing in the parent class allowed for potential + directory-traversal via certain inputs when calling save(). Built-in + Storage sub-classes were not affected by this vulnerability. + + * CVE-2024-39614: Fix a potential denial-of-service in + django.utils.translation.get_supported_language_variant. This method was + subject to a potential DoS attack when used with very long strings + containing specific characters. To mitigate this vulnerability, the + language code provided to get_supported_language_variant is now parsed up + to a maximum length of 500 characters. + + * CVE-2024-41989: Memory exhaustion in django.utils.numberformat. The + floatformat template filter is subject to significant memory consumption + when given a string representation of a number in scientific notation with + a large exponent. + + * CVE-2024-41991: Potential denial-of-service vulnerability in + django.utils.html.urlize() and AdminURLFieldWidget. The urlize and + urlizetrunc template filters, and the AdminURLFieldWidget widget, are + subject to a potential denial-of-service attack via certain inputs with a + very large number of Unicode characters. + + * CVE-2024-42005: Potential SQL injection in QuerySet.values() and + values_list(). QuerySet.values() and values_list() methods on models with a + JSONField are subject to SQL injection in column aliases via a crafted JSON + object key as a passed *arg. + + * CVE-2024-45231: Potential user email enumeration via response status on + password reset. Due to unhandled email sending failures, the + django.contrib.auth.forms.PasswordResetForm class allowed remote attackers + to enumerate user emails by issuing password reset requests and observing + the outcomes. To mitigate this risk, exceptions occurring during password + reset email sending are now handled and logged using the + django.contrib.auth logger. + + * CVE-2024-53907: Potential DoS in django.utils.html.strip_tags. + The strip_tags() method and striptags template filter were subject to a + potential denial-of-service attack via certain inputs containing large + sequences of nested incomplete HTML entities. + + * CVE-2024-56374: Potential denial-of-service vulnerability in IPv6 + validation. A lack of upper bound limit enforcement in strings passed when + performing IPv6 validation could have led to a potential denial-of-service + (DoS) attack. The undocumented and private functions clean_ipv6_address and + is_valid_ipv6_address were vulnerable, as was the GenericIPAddressField + form field, which has now been updated to define a max_length of 39 + characters. The GenericIPAddressField model field was not affected. + + * 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-26699: Address a potential denial-of-service in + django.utils.text.wrap. The wrap() method and wordwrap template filter were + subject to a potential denial-of-service attack when used with very long + strings. (Closes: #1099682) + + * CVE-2025-32873: Denial-of-service possibility in strip_tags() + django.utils.html.strip_tags() would be slow to evaluate certain inputs + containing large sequences of incomplete HTML tags. This function is used + to implement the striptags template filter, which was therefore also + vulnerable. strip_tags() now raises a SuspiciousOperation exception if it + encounters an unusually large number of unclosed opening tags. + + * CVE-2025-48432: Potential log injection via unescaped request path. + Django's internal HTTP response logging used request.path directly, + allowing control characters (e.g. newlines or ANSI escape sequences) to be + written unescaped into logs. This could enable log injection or forgery, + letting attackers manipulate log appearance or structure, especially in + logs processed by external systems or viewed in terminals. Although this + does not directly impact Django's security model, it poses risks when logs + are consumed or interpreted by other tools. To fix this, the internal + django.utils.log.log_response() function now escapes all positional + formatting arguments using a safe encoding. + + * 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-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. + + * 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. + + * 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. + + * 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. + + -- Chris Lamb Tue, 27 Jan 2026 11:16:59 -0800 python-django (3:3.2.19-1+deb12u1) bookworm-security; urgency=high diff -Nru python-django-3.2.19/debian/patches/0013-fix-url-validator.patch python-django-3.2.25/debian/patches/0013-fix-url-validator.patch --- python-django-3.2.19/debian/patches/0013-fix-url-validator.patch 2024-08-07 15:56:53.000000000 +0000 +++ python-django-3.2.25/debian/patches/0013-fix-url-validator.patch 2026-01-27 19:16:59.000000000 +0000 @@ -10,10 +10,10 @@ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/django/core/validators.py b/django/core/validators.py -index 731ccf2d4690..6b28eef08dd2 100644 +index b9b58dfa6176..52ebddac6345 100644 --- a/django/core/validators.py +++ b/django/core/validators.py -@@ -110,15 +110,16 @@ class URLValidator(RegexValidator): +@@ -111,15 +111,16 @@ class URLValidator(RegexValidator): raise ValidationError(self.message, code=self.code, params={'value': value}) # Then check full URL @@ -34,7 +34,7 @@ try: netloc = punycode(netloc) # IDN -> ACE except UnicodeError: # invalid domain part -@@ -129,7 +130,7 @@ class URLValidator(RegexValidator): +@@ -130,7 +131,7 @@ class URLValidator(RegexValidator): raise else: # Now verify IPv6 in the netloc part @@ -43,7 +43,7 @@ if host_match: potential_ip = host_match[1] try: -@@ -141,7 +142,7 @@ class URLValidator(RegexValidator): +@@ -142,7 +143,7 @@ class URLValidator(RegexValidator): # section 3.1. It's defined to be 255 bytes or less, but this includes # one byte for the length of the name and one byte for the trailing dot # that's used to indicate absolute names in DNS. diff -Nru python-django-3.2.19/debian/patches/0014-CVE-2023-36053.patch python-django-3.2.25/debian/patches/0014-CVE-2023-36053.patch --- python-django-3.2.19/debian/patches/0014-CVE-2023-36053.patch 2024-08-07 15:56:53.000000000 +0000 +++ python-django-3.2.25/debian/patches/0014-CVE-2023-36053.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,242 +0,0 @@ -From: Mariusz Felisiak -Date: Wed, 14 Jun 2023 12:23:06 +0200 -Subject: [PATCH] [3.2.x] Fixed CVE-2023-36053 -- Prevented potential ReDoS in - EmailValidator and URLValidator. - -Thanks Seokchan Yoon for reports. ---- - django/core/validators.py | 7 +++++-- - django/forms/fields.py | 3 +++ - docs/ref/forms/fields.txt | 7 ++++++- - docs/ref/validators.txt | 25 +++++++++++++++++++++++- - tests/forms_tests/field_tests/test_emailfield.py | 5 ++++- - tests/forms_tests/tests/test_forms.py | 19 ++++++++++++------ - tests/validators/tests.py | 11 +++++++++++ - 7 files changed, 66 insertions(+), 11 deletions(-) - -diff --git a/django/core/validators.py b/django/core/validators.py -index 6b28eef08dd2..52ebddac6345 100644 ---- a/django/core/validators.py -+++ b/django/core/validators.py -@@ -93,6 +93,7 @@ class URLValidator(RegexValidator): - message = _('Enter a valid URL.') - schemes = ['http', 'https', 'ftp', 'ftps'] - unsafe_chars = frozenset('\t\r\n') -+ max_length = 2048 - - def __init__(self, schemes=None, **kwargs): - super().__init__(**kwargs) -@@ -100,7 +101,7 @@ class URLValidator(RegexValidator): - self.schemes = schemes - - def __call__(self, value): -- if not isinstance(value, str): -+ if not isinstance(value, str) or len(value) > self.max_length: - raise ValidationError(self.message, code=self.code, params={'value': value}) - if self.unsafe_chars.intersection(value): - raise ValidationError(self.message, code=self.code, params={'value': value}) -@@ -211,7 +212,9 @@ class EmailValidator: - self.domain_allowlist = allowlist - - def __call__(self, value): -- if not value or '@' not in value: -+ # The maximum length of an email is 320 characters per RFC 3696 -+ # section 3. -+ if not value or '@' not in value or len(value) > 320: - raise ValidationError(self.message, code=self.code, params={'value': value}) - - user_part, domain_part = value.rsplit('@', 1) -diff --git a/django/forms/fields.py b/django/forms/fields.py -index 0214d60c1cf1..8adb09e38294 100644 ---- a/django/forms/fields.py -+++ b/django/forms/fields.py -@@ -540,6 +540,9 @@ class EmailField(CharField): - default_validators = [validators.validate_email] - - def __init__(self, **kwargs): -+ # The default maximum length of an email is 320 characters per RFC 3696 -+ # section 3. -+ kwargs.setdefault("max_length", 320) - super().__init__(strip=True, **kwargs) - - -diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt -index 9438214a28ce..5b485f215384 100644 ---- a/docs/ref/forms/fields.txt -+++ b/docs/ref/forms/fields.txt -@@ -592,7 +592,12 @@ For each field, we describe the default widget used if you don't specify - * Error message keys: ``required``, ``invalid`` - - Has three optional arguments ``max_length``, ``min_length``, and -- ``empty_value`` which work just as they do for :class:`CharField`. -+ ``empty_value`` which work just as they do for :class:`CharField`. The -+ ``max_length`` argument defaults to 320 (see :rfc:`3696#section-3`). -+ -+ .. versionchanged:: 3.2.20 -+ -+ The default value for ``max_length`` was changed to 320 characters. - - ``FileField`` - ------------- -diff --git a/docs/ref/validators.txt b/docs/ref/validators.txt -index 50761e5a425c..b22762b17b93 100644 ---- a/docs/ref/validators.txt -+++ b/docs/ref/validators.txt -@@ -130,6 +130,11 @@ to, or in lieu of custom ``field.clean()`` methods. - :param code: If not ``None``, overrides :attr:`code`. - :param allowlist: If not ``None``, overrides :attr:`allowlist`. - -+ An :class:`EmailValidator` ensures that a value looks like an email, and -+ raises a :exc:`~django.core.exceptions.ValidationError` with -+ :attr:`message` and :attr:`code` if it doesn't. Values longer than 320 -+ characters are always considered invalid. -+ - .. attribute:: message - - The error message used by -@@ -158,13 +163,19 @@ to, or in lieu of custom ``field.clean()`` methods. - The undocumented ``domain_whitelist`` attribute is deprecated. Use - ``domain_allowlist`` instead. - -+ .. versionchanged:: 3.2.20 -+ -+ In older versions, values longer than 320 characters could be -+ considered valid. -+ - ``URLValidator`` - ---------------- - - .. class:: URLValidator(schemes=None, regex=None, message=None, code=None) - - A :class:`RegexValidator` subclass that ensures a value looks like a URL, -- and raises an error code of ``'invalid'`` if it doesn't. -+ and raises an error code of ``'invalid'`` if it doesn't. Values longer than -+ :attr:`max_length` characters are always considered invalid. - - Loopback addresses and reserved IP spaces are considered valid. Literal - IPv6 addresses (:rfc:`3986#section-3.2.2`) and Unicode domains are both -@@ -181,6 +192,18 @@ to, or in lieu of custom ``field.clean()`` methods. - - .. _valid URI schemes: https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml - -+ .. attribute:: max_length -+ -+ .. versionadded:: 3.2.20 -+ -+ The maximum length of values that could be considered valid. Defaults -+ to 2048 characters. -+ -+ .. versionchanged:: 3.2.20 -+ -+ In older versions, values longer than 2048 characters could be -+ considered valid. -+ - ``validate_email`` - ------------------ - -diff --git a/tests/forms_tests/field_tests/test_emailfield.py b/tests/forms_tests/field_tests/test_emailfield.py -index 8b85e4dcc144..19d315205d7e 100644 ---- a/tests/forms_tests/field_tests/test_emailfield.py -+++ b/tests/forms_tests/field_tests/test_emailfield.py -@@ -9,7 +9,10 @@ class EmailFieldTest(FormFieldAssertionsMixin, SimpleTestCase): - - def test_emailfield_1(self): - f = EmailField() -- self.assertWidgetRendersTo(f, '') -+ self.assertEqual(f.max_length, 320) -+ self.assertWidgetRendersTo( -+ f, '' -+ ) - with self.assertRaisesMessage(ValidationError, "'This field is required.'"): - f.clean('') - with self.assertRaisesMessage(ValidationError, "'This field is required.'"): -diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py -index 26f8ecafea44..82a32af403a0 100644 ---- a/tests/forms_tests/tests/test_forms.py -+++ b/tests/forms_tests/tests/test_forms.py -@@ -422,11 +422,18 @@ class FormsTestCase(SimpleTestCase): - get_spam = BooleanField() - - f = SignupForm(auto_id=False) -- self.assertHTMLEqual(str(f['email']), '') -+ self.assertHTMLEqual( -+ str(f["email"]), -+ '', -+ ) - self.assertHTMLEqual(str(f['get_spam']), '') - - f = SignupForm({'email': 'test@example.com', 'get_spam': True}, auto_id=False) -- self.assertHTMLEqual(str(f['email']), '') -+ self.assertHTMLEqual( -+ str(f["email"]), -+ '", -+ ) - self.assertHTMLEqual( - str(f['get_spam']), - '', -@@ -2824,7 +2831,7 @@ Good luck picking a username that doesn't already exist.

- - - --
  • -+
  • -
    • This field is required.
    -
  • """ - ) -@@ -2840,7 +2847,7 @@ Good luck picking a username that doesn't already exist.

    - - -

    --

    -+

    - -

    -

    """ -@@ -2859,7 +2866,7 @@ Good luck picking a username that doesn't already exist.

    - - - -- -+ - - - """ -@@ -3489,7 +3496,7 @@ Good luck picking a username that doesn't already exist.

    - f = CommentForm(data, auto_id=False, error_class=DivErrorList) - self.assertHTMLEqual(f.as_p(), """

    Name:

    -
    Enter a valid email address.
    --

    Email:

    -+

    Email:

    -
    This field is required.
    -

    Comment:

    """) - -diff --git a/tests/validators/tests.py b/tests/validators/tests.py -index e39d0e3a1cef..1065727a974e 100644 ---- a/tests/validators/tests.py -+++ b/tests/validators/tests.py -@@ -59,6 +59,7 @@ TEST_DATA = [ - - (validate_email, 'example@atm.%s' % ('a' * 64), ValidationError), - (validate_email, 'example@%s.atm.%s' % ('b' * 64, 'a' * 63), ValidationError), -+ (validate_email, "example@%scom" % (("a" * 63 + ".") * 100), ValidationError), - (validate_email, None, ValidationError), - (validate_email, '', ValidationError), - (validate_email, 'abc', ValidationError), -@@ -246,6 +247,16 @@ TEST_DATA = [ - (URLValidator(), None, ValidationError), - (URLValidator(), 56, ValidationError), - (URLValidator(), 'no_scheme', ValidationError), -+ ( -+ URLValidator(), -+ "http://example." + ("a" * 63 + ".") * 1000 + "com", -+ ValidationError, -+ ), -+ ( -+ URLValidator(), -+ "http://userid:password" + "d" * 2000 + "@example.aaaaaaaaaaaaa.com", -+ None, -+ ), - # Newlines and tabs are not accepted. - (URLValidator(), 'http://www.djangoproject.com/\n', ValidationError), - (URLValidator(), 'http://[::ffff:192.9.5.5]\n', ValidationError), diff -Nru python-django-3.2.19/debian/patches/0014-CVE-2024-39329.patch python-django-3.2.25/debian/patches/0014-CVE-2024-39329.patch --- python-django-3.2.19/debian/patches/0014-CVE-2024-39329.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0014-CVE-2024-39329.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,83 @@ +From: Michael Manfre +Date: Fri, 14 Jun 2024 22:12:58 -0400 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-39329 -- Standarized timing of + verify_password() when checking unusuable passwords. + +Refs #20760. + +Thanks Michael Manfre for the fix and to Adam Johnson for the review. +--- + django/contrib/auth/hashers.py | 10 ++++++++-- + tests/auth_tests/test_hashers.py | 32 ++++++++++++++++++++++++++++++++ + 2 files changed, 40 insertions(+), 2 deletions(-) + +diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py +index 86ae7f42a8b1..ee81b641dca5 100644 +--- a/django/contrib/auth/hashers.py ++++ b/django/contrib/auth/hashers.py +@@ -36,14 +36,20 @@ def check_password(password, encoded, setter=None, preferred='default'): + If setter is specified, it'll be called when you need to + regenerate the password. + """ +- if password is None or not is_password_usable(encoded): +- return False ++ fake_runtime = password is None or not is_password_usable(encoded) + + preferred = get_hasher(preferred) + try: + hasher = identify_hasher(encoded) + except ValueError: + # encoded is gibberish or uses a hasher that's no longer installed. ++ fake_runtime = True ++ ++ if fake_runtime: ++ # Run the default password hasher once to reduce the timing difference ++ # between an existing user with an unusable password and a nonexistent ++ # user or missing hasher (similar to #20760). ++ make_password(get_random_string(UNUSABLE_PASSWORD_SUFFIX_LENGTH)) + return False + + hasher_changed = hasher.algorithm != preferred.algorithm +diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py +index 8bc61bc8b292..df47adea7805 100644 +--- a/tests/auth_tests/test_hashers.py ++++ b/tests/auth_tests/test_hashers.py +@@ -272,6 +272,38 @@ class TestUtilsHashPass(SimpleTestCase): + expected_call = (('wrong_password', data[:29].encode()),) + self.assertEqual(hasher.encode.call_args_list, [expected_call] * 3) + ++ def test_check_password_calls_make_password_to_fake_runtime(self): ++ hasher = get_hasher("default") ++ cases = [ ++ (None, None, None), # no plain text password provided ++ ("foo", make_password(password=None), None), # unusable encoded ++ ("letmein", make_password(password="letmein"), ValueError), # valid encoded ++ ] ++ for password, encoded, hasher_side_effect in cases: ++ with ( ++ self.subTest(encoded=encoded), ++ mock.patch( ++ "django.contrib.auth.hashers.identify_hasher", ++ side_effect=hasher_side_effect, ++ ) as mock_identify_hasher, ++ mock.patch( ++ "django.contrib.auth.hashers.make_password" ++ ) as mock_make_password, ++ mock.patch( ++ "django.contrib.auth.hashers.get_random_string", ++ side_effect=lambda size: "x" * size, ++ ), ++ mock.patch.object(hasher, "verify"), ++ ): ++ # Ensure make_password is called to standardize timing. ++ check_password(password, encoded) ++ self.assertEqual(hasher.verify.call_count, 0) ++ self.assertEqual(mock_identify_hasher.mock_calls, [mock.call(encoded)]) ++ self.assertEqual( ++ mock_make_password.mock_calls, ++ [mock.call("x" * UNUSABLE_PASSWORD_SUFFIX_LENGTH)], ++ ) ++ + def test_unusable(self): + encoded = make_password(None) + self.assertEqual(len(encoded), len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH) diff -Nru python-django-3.2.19/debian/patches/0015-CVE-2024-39329.patch python-django-3.2.25/debian/patches/0015-CVE-2024-39329.patch --- python-django-3.2.19/debian/patches/0015-CVE-2024-39329.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0015-CVE-2024-39329.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,80 +0,0 @@ -commit 5d8645857936c142a3973694799c52165e2bdcdb -Author: Michael Manfre -Date: Fri Jun 14 22:12:58 2024 -0400 - - Fixed CVE-2024-39329 -- Standarized timing of verify_password() when checking unusuable passwords. - - Refs #20760. - - Thanks Michael Manfre for the fix and to Adam Johnson for the review. - -diff --git a/django/contrib/auth/hashers.py b/django/contrib/auth/hashers.py -index 86ae7f42a..ee81b641d 100644 ---- a/django/contrib/auth/hashers.py -+++ b/django/contrib/auth/hashers.py -@@ -36,14 +36,20 @@ def check_password(password, encoded, setter=None, preferred='default'): - If setter is specified, it'll be called when you need to - regenerate the password. - """ -- if password is None or not is_password_usable(encoded): -- return False -+ fake_runtime = password is None or not is_password_usable(encoded) - - preferred = get_hasher(preferred) - try: - hasher = identify_hasher(encoded) - except ValueError: - # encoded is gibberish or uses a hasher that's no longer installed. -+ fake_runtime = True -+ -+ if fake_runtime: -+ # Run the default password hasher once to reduce the timing difference -+ # between an existing user with an unusable password and a nonexistent -+ # user or missing hasher (similar to #20760). -+ make_password(get_random_string(UNUSABLE_PASSWORD_SUFFIX_LENGTH)) - return False - - hasher_changed = hasher.algorithm != preferred.algorithm -diff --git a/tests/auth_tests/test_hashers.py b/tests/auth_tests/test_hashers.py -index 8bc61bc8b..a1ae9400e 100644 ---- a/tests/auth_tests/test_hashers.py -+++ b/tests/auth_tests/test_hashers.py -@@ -474,6 +474,38 @@ class TestUtilsHashPass(SimpleTestCase): - check_password('wrong_password', encoded) - self.assertEqual(hasher.harden_runtime.call_count, 1) - -+ def test_check_password_calls_make_password_to_fake_runtime(self): -+ hasher = get_hasher("default") -+ cases = [ -+ (None, None, None), # no plain text password provided -+ ("foo", make_password(password=None), None), # unusable encoded -+ ("letmein", make_password(password="letmein"), ValueError), # valid encoded -+ ] -+ for password, encoded, hasher_side_effect in cases: -+ with ( -+ self.subTest(encoded=encoded), -+ mock.patch( -+ "django.contrib.auth.hashers.identify_hasher", -+ side_effect=hasher_side_effect, -+ ) as mock_identify_hasher, -+ mock.patch( -+ "django.contrib.auth.hashers.make_password" -+ ) as mock_make_password, -+ mock.patch( -+ "django.contrib.auth.hashers.get_random_string", -+ side_effect=lambda size: "x" * size, -+ ), -+ mock.patch.object(hasher, "verify"), -+ ): -+ # Ensure make_password is called to standardize timing. -+ check_password(password, encoded) -+ self.assertEqual(hasher.verify.call_count, 0) -+ self.assertEqual(mock_identify_hasher.mock_calls, [mock.call(encoded)]) -+ self.assertEqual( -+ mock_make_password.mock_calls, -+ [mock.call("x" * UNUSABLE_PASSWORD_SUFFIX_LENGTH)], -+ ) -+ - - class BasePasswordHasherTests(SimpleTestCase): - not_implemented_msg = 'subclasses of BasePasswordHasher must provide %s() method' diff -Nru python-django-3.2.19/debian/patches/0015-CVE-2024-39330.patch python-django-3.2.25/debian/patches/0015-CVE-2024-39330.patch --- python-django-3.2.19/debian/patches/0015-CVE-2024-39330.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0015-CVE-2024-39330.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,149 @@ +From: Natalia <124304+nessita@users.noreply.github.com> +Date: Wed, 20 Mar 2024 13:55:21 -0300 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-39330 -- Added extra file name + validation in Storage's save method. + +Thanks to Josh Schneier for the report, and to Carlton Gibson and Sarah +Boyce for the reviews. +--- + django/core/files/storage.py | 11 +++++++ + django/core/files/utils.py | 7 ++--- + tests/file_storage/test_base.py | 70 +++++++++++++++++++++++++++++++++++++++++ + tests/file_storage/tests.py | 6 ---- + 4 files changed, 84 insertions(+), 10 deletions(-) + create mode 100644 tests/file_storage/test_base.py + +diff --git a/django/core/files/storage.py b/django/core/files/storage.py +index 22984f9498d9..680f5ec91167 100644 +--- a/django/core/files/storage.py ++++ b/django/core/files/storage.py +@@ -50,7 +50,18 @@ class Storage: + if not hasattr(content, 'chunks'): + content = File(content, name) + ++ # Ensure that the name is valid, before and after having the storage ++ # system potentially modifying the name. This duplicates the check made ++ # inside `get_available_name` but it's necessary for those cases where ++ # `get_available_name` is overriden and validation is lost. ++ validate_file_name(name, allow_relative_path=True) ++ ++ # Potentially find a different name depending on storage constraints. + name = self.get_available_name(name, max_length=max_length) ++ # Validate the (potentially) new name. ++ validate_file_name(name, allow_relative_path=True) ++ ++ # The save operation should return the actual name of the file saved. + name = self._save(name, content) + # Ensure that the name returned from the storage system is still valid. + validate_file_name(name, allow_relative_path=True) +diff --git a/django/core/files/utils.py b/django/core/files/utils.py +index f28cea107758..a1fea44ded67 100644 +--- a/django/core/files/utils.py ++++ b/django/core/files/utils.py +@@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False): + raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) + + if allow_relative_path: +- # Use PurePosixPath() because this branch is checked only in +- # FileField.generate_filename() where all file paths are expected to be +- # Unix style (with forward slashes). +- path = pathlib.PurePosixPath(name) ++ # Ensure that name can be treated as a pure posix path, i.e. Unix ++ # style (with forward slashes). ++ path = pathlib.PurePosixPath(str(name).replace("\\", "/")) + if path.is_absolute() or '..' in path.parts: + raise SuspiciousFileOperation( + "Detected path traversal attempt in '%s'" % name +diff --git a/tests/file_storage/test_base.py b/tests/file_storage/test_base.py +new file mode 100644 +index 000000000000..c5338b8e668f +--- /dev/null ++++ b/tests/file_storage/test_base.py +@@ -0,0 +1,70 @@ ++import os ++from unittest import mock ++ ++from django.core.exceptions import SuspiciousFileOperation ++from django.core.files.storage import Storage ++from django.test import SimpleTestCase ++ ++ ++class CustomStorage(Storage): ++ """Simple Storage subclass implementing the bare minimum for testing.""" ++ ++ def exists(self, name): ++ return False ++ ++ def _save(self, name): ++ return name ++ ++ ++class StorageValidateFileNameTests(SimpleTestCase): ++ invalid_file_names = [ ++ os.path.join("path", "to", os.pardir, "test.file"), ++ os.path.join(os.path.sep, "path", "to", "test.file"), ++ ] ++ error_msg = "Detected path traversal attempt in '%s'" ++ ++ def test_validate_before_get_available_name(self): ++ s = CustomStorage() ++ # The initial name passed to `save` is not valid nor safe, fail early. ++ for name in self.invalid_file_names: ++ with ( ++ self.subTest(name=name), ++ mock.patch.object(s, "get_available_name") as mock_get_available_name, ++ mock.patch.object(s, "_save") as mock_internal_save, ++ ): ++ with self.assertRaisesMessage( ++ SuspiciousFileOperation, self.error_msg % name ++ ): ++ s.save(name, content="irrelevant") ++ self.assertEqual(mock_get_available_name.mock_calls, []) ++ self.assertEqual(mock_internal_save.mock_calls, []) ++ ++ def test_validate_after_get_available_name(self): ++ s = CustomStorage() ++ # The initial name passed to `save` is valid and safe, but the returned ++ # name from `get_available_name` is not. ++ for name in self.invalid_file_names: ++ with ( ++ self.subTest(name=name), ++ mock.patch.object(s, "get_available_name", return_value=name), ++ mock.patch.object(s, "_save") as mock_internal_save, ++ ): ++ with self.assertRaisesMessage( ++ SuspiciousFileOperation, self.error_msg % name ++ ): ++ s.save("valid-file-name.txt", content="irrelevant") ++ self.assertEqual(mock_internal_save.mock_calls, []) ++ ++ def test_validate_after_internal_save(self): ++ s = CustomStorage() ++ # The initial name passed to `save` is valid and safe, but the result ++ # from `_save` is not (this is achieved by monkeypatching _save). ++ for name in self.invalid_file_names: ++ with ( ++ self.subTest(name=name), ++ mock.patch.object(s, "_save", return_value=name), ++ ): ++ with self.assertRaisesMessage( ++ SuspiciousFileOperation, self.error_msg % name ++ ): ++ s.save("valid-file-name.txt", content="irrelevant") +diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py +index 72380932447f..6d17a7118b4e 100644 +--- a/tests/file_storage/tests.py ++++ b/tests/file_storage/tests.py +@@ -297,12 +297,6 @@ class FileStorageTests(SimpleTestCase): + + self.storage.delete('path/to/test.file') + +- def test_file_save_abs_path(self): +- test_name = 'path/to/test.file' +- f = ContentFile('file saved with path') +- f_name = self.storage.save(os.path.join(self.temp_dir, test_name), f) +- self.assertEqual(f_name, test_name) +- + def test_save_doesnt_close(self): + with TemporaryUploadedFile('test', 'text/plain', 1, 'utf8') as file: + file.write(b'1') diff -Nru python-django-3.2.19/debian/patches/0016-CVE-2024-39330.patch python-django-3.2.25/debian/patches/0016-CVE-2024-39330.patch --- python-django-3.2.19/debian/patches/0016-CVE-2024-39330.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0016-CVE-2024-39330.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,145 +0,0 @@ -commit fe4a0bbe2088d0c2b331216dad21ccd0bb3ee80d -Author: Natalia <124304+nessita@users.noreply.github.com> -Date: Wed Mar 20 13:55:21 2024 -0300 - - Fixed CVE-2024-39330 -- Added extra file name validation in Storage's save method. - - Thanks to Josh Schneier for the report, and to Carlton Gibson and Sarah - Boyce for the reviews. - -diff --git a/django/core/files/storage.py b/django/core/files/storage.py -index 22984f949..680f5ec91 100644 ---- a/django/core/files/storage.py -+++ b/django/core/files/storage.py -@@ -50,7 +50,18 @@ class Storage: - if not hasattr(content, 'chunks'): - content = File(content, name) - -+ # Ensure that the name is valid, before and after having the storage -+ # system potentially modifying the name. This duplicates the check made -+ # inside `get_available_name` but it's necessary for those cases where -+ # `get_available_name` is overriden and validation is lost. -+ validate_file_name(name, allow_relative_path=True) -+ -+ # Potentially find a different name depending on storage constraints. - name = self.get_available_name(name, max_length=max_length) -+ # Validate the (potentially) new name. -+ validate_file_name(name, allow_relative_path=True) -+ -+ # The save operation should return the actual name of the file saved. - name = self._save(name, content) - # Ensure that the name returned from the storage system is still valid. - validate_file_name(name, allow_relative_path=True) -diff --git a/django/core/files/utils.py b/django/core/files/utils.py -index 611f932f6e..c730ca17e8 100644 ---- a/django/core/files/utils.py -+++ b/django/core/files/utils.py -@@ -10,10 +10,9 @@ def validate_file_name(name, allow_relative_path=False): - raise SuspiciousFileOperation("Could not derive file name from '%s'" % name) - - if allow_relative_path: -- # Use PurePosixPath() because this branch is checked only in -- # FileField.generate_filename() where all file paths are expected to be -- # Unix style (with forward slashes). -- path = pathlib.PurePosixPath(name) -+ # Ensure that name can be treated as a pure posix path, i.e. Unix -+ # style (with forward slashes). -+ path = pathlib.PurePosixPath(str(name).replace("\\", "/")) - if path.is_absolute() or '..' in path.parts: - raise SuspiciousFileOperation( - "Detected path traversal attempt in '%s'" % name -diff --git a/tests/file_storage/test_base.py b/tests/file_storage/test_base.py -new file mode 100644 -index 0000000000..712d3ba2e2 ---- /dev/null -+++ b/tests/file_storage/test_base.py -@@ -0,0 +1,72 @@ -+import os -+from unittest import mock -+ -+from django.core.exceptions import SuspiciousFileOperation -+from django.core.files.storage import Storage -+from django.test import SimpleTestCase -+ -+ -+class CustomStorage(Storage): -+ """Simple Storage subclass implementing the bare minimum for testing.""" -+ -+ def exists(self, name): -+ return False -+ -+ def _save(self, name): -+ return name -+ -+ -+class StorageValidateFileNameTests(SimpleTestCase): -+ -+ invalid_file_names = [ -+ os.path.join("path", "to", os.pardir, "test.file"), -+ os.path.join(os.path.sep, "path", "to", "test.file"), -+ ] -+ error_msg = "Detected path traversal attempt in '%s'" -+ -+ def test_validate_before_get_available_name(self): -+ s = CustomStorage() -+ # The initial name passed to `save` is not valid nor safe, fail early. -+ for name in self.invalid_file_names: -+ with ( -+ self.subTest(name=name), -+ mock.patch.object(s, "get_available_name") as mock_get_available_name, -+ mock.patch.object(s, "_save") as mock_internal_save, -+ ): -+ with self.assertRaisesMessage( -+ SuspiciousFileOperation, self.error_msg % name -+ ): -+ s.save(name, content="irrelevant") -+ self.assertEqual(mock_get_available_name.mock_calls, []) -+ self.assertEqual(mock_internal_save.mock_calls, []) -+ -+ def test_validate_after_get_available_name(self): -+ s = CustomStorage() -+ # The initial name passed to `save` is valid and safe, but the returned -+ # name from `get_available_name` is not. -+ for name in self.invalid_file_names: -+ with ( -+ self.subTest(name=name), -+ mock.patch.object(s, "get_available_name", return_value=name), -+ mock.patch.object(s, "_save") as mock_internal_save, -+ ): -+ with self.assertRaisesMessage( -+ SuspiciousFileOperation, self.error_msg % name -+ ): -+ s.save("valid-file-name.txt", content="irrelevant") -+ self.assertEqual(mock_internal_save.mock_calls, []) -+ -+ def test_validate_after_internal_save(self): -+ s = CustomStorage() -+ # The initial name passed to `save` is valid and safe, but the result -+ # from `_save` is not (this is achieved by monkeypatching _save). -+ for name in self.invalid_file_names: -+ with ( -+ self.subTest(name=name), -+ mock.patch.object(s, "_save", return_value=name), -+ ): -+ -+ with self.assertRaisesMessage( -+ SuspiciousFileOperation, self.error_msg % name -+ ): -+ s.save("valid-file-name.txt", content="irrelevant") -diff --git a/tests/file_storage/tests.py b/tests/file_storage/tests.py -index 723809324..6d17a7118 100644 ---- a/tests/file_storage/tests.py -+++ b/tests/file_storage/tests.py -@@ -297,12 +297,6 @@ class FileStorageTests(SimpleTestCase): - - self.storage.delete('path/to/test.file') - -- def test_file_save_abs_path(self): -- test_name = 'path/to/test.file' -- f = ContentFile('file saved with path') -- f_name = self.storage.save(os.path.join(self.temp_dir, test_name), f) -- self.assertEqual(f_name, test_name) -- - def test_save_doesnt_close(self): - with TemporaryUploadedFile('test', 'text/plain', 1, 'utf8') as file: - file.write(b'1') diff -Nru python-django-3.2.19/debian/patches/0016-CVE-2024-39614.patch python-django-3.2.25/debian/patches/0016-CVE-2024-39614.patch --- python-django-3.2.19/debian/patches/0016-CVE-2024-39614.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0016-CVE-2024-39614.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,129 @@ +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Wed, 26 Jun 2024 12:11:54 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-39614 -- Mitigated potential DoS in + get_supported_language_variant(). + +Language codes are now parsed with a maximum length limit of 500 chars. + +Thanks to MProgrammer for the report. +--- + django/utils/translation/trans_real.py | 23 ++++++++++++++++++----- + docs/ref/utils.txt | 10 ++++++++++ + tests/i18n/tests.py | 11 +++++++++++ + 3 files changed, 39 insertions(+), 5 deletions(-) + +diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py +index b262a5000a48..7c4bf095a94c 100644 +--- a/django/utils/translation/trans_real.py ++++ b/django/utils/translation/trans_real.py +@@ -31,9 +31,10 @@ _default = None + CONTEXT_SEPARATOR = "\x04" + + # Maximum number of characters that will be parsed from the Accept-Language +-# header to prevent possible denial of service or memory exhaustion attacks. +-# About 10x longer than the longest value shown on MDN’s Accept-Language page. +-ACCEPT_LANGUAGE_HEADER_MAX_LENGTH = 500 ++# header or cookie to prevent possible denial of service or memory exhaustion ++# attacks. About 10x longer than the longest value shown on MDN’s ++# Accept-Language page. ++LANGUAGE_CODE_MAX_LENGTH = 500 + + # Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9 + # and RFC 3066, section 2.1 +@@ -474,11 +475,23 @@ def get_supported_language_variant(lang_code, strict=False): + If `strict` is False (the default), look for a country-specific variant + when neither the language code nor its generic variant is found. + ++ The language code is truncated to a maximum length to avoid potential ++ denial of service attacks. ++ + lru_cache should have a maxsize to prevent from memory exhaustion attacks, + as the provided language codes are taken from the HTTP request. See also + . + """ + if lang_code: ++ # Truncate the language code to a maximum length to avoid potential ++ # denial of service attacks. ++ if len(lang_code) > LANGUAGE_CODE_MAX_LENGTH: ++ index = lang_code.rfind("-", 0, LANGUAGE_CODE_MAX_LENGTH) ++ if not strict and index > 0: ++ # There is a generic variant under the maximum length accepted length. ++ lang_code = lang_code[:index] ++ else: ++ raise ValueError("'lang_code' exceeds the maximum accepted length") + # If 'fr-ca' is not supported, try special fallback or language-only 'fr'. + possible_lang_codes = [lang_code] + try: +@@ -595,13 +608,13 @@ def parse_accept_lang_header(lang_string): + functools.lru_cache() to avoid repetitive parsing of common header values. + """ + # If the header value doesn't exceed the maximum allowed length, parse it. +- if len(lang_string) <= ACCEPT_LANGUAGE_HEADER_MAX_LENGTH: ++ if len(lang_string) <= LANGUAGE_CODE_MAX_LENGTH: + return _parse_accept_lang_header(lang_string) + + # If there is at least one comma in the value, parse up to the last comma + # before the max length, skipping any truncated parts at the end of the + # header value. +- index = lang_string.rfind(",", 0, ACCEPT_LANGUAGE_HEADER_MAX_LENGTH) ++ index = lang_string.rfind(",", 0, LANGUAGE_CODE_MAX_LENGTH) + if index > 0: + return _parse_accept_lang_header(lang_string[:index]) + +diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt +index 0c296f8d55c4..fa97b98d6f3b 100644 +--- a/docs/ref/utils.txt ++++ b/docs/ref/utils.txt +@@ -1129,6 +1129,11 @@ For a complete discussion on the usage of the following see the + ``lang_code`` is ``'es-ar'`` and ``'es'`` is in :setting:`LANGUAGES` but + ``'es-ar'`` isn't. + ++ ``lang_code`` has a maximum accepted length of 500 characters. A ++ :exc:`ValueError` is raised if ``lang_code`` exceeds this limit and ++ ``strict`` is ``True``, or if there is no generic variant and ``strict`` ++ is ``False``. ++ + If ``strict`` is ``False`` (the default), a country-specific variant may + be returned when neither the language code nor its generic variant is found. + For example, if only ``'es-co'`` is in :setting:`LANGUAGES`, that's +@@ -1137,6 +1142,11 @@ For a complete discussion on the usage of the following see the + + Raises :exc:`LookupError` if nothing is found. + ++ .. versionchanged:: 4.2.14 ++ ++ In older versions, ``lang_code`` values over 500 characters were ++ processed without raising a :exc:`ValueError`. ++ + .. function:: to_locale(language) + + Turns a language name (en-us) into a locale name (en_US). +diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py +index 41ec63da99e4..793bd3db8402 100644 +--- a/tests/i18n/tests.py ++++ b/tests/i18n/tests.py +@@ -40,6 +40,7 @@ from django.utils.translation import ( + from django.utils.translation.reloader import ( + translation_file_changed, watch_for_translation_changes, + ) ++from django.utils.translation.trans_real import LANGUAGE_CODE_MAX_LENGTH + + from .forms import CompanyForm, I18nForm, SelectDateForm + from .models import Company, TestModel +@@ -1532,6 +1533,16 @@ class MiscTests(SimpleTestCase): + g('xyz') + with self.assertRaises(LookupError): + g('xy-zz') ++ msg = "'lang_code' exceeds the maximum accepted length" ++ with self.assertRaises(LookupError): ++ g("x" * LANGUAGE_CODE_MAX_LENGTH) ++ with self.assertRaisesMessage(ValueError, msg): ++ g("x" * (LANGUAGE_CODE_MAX_LENGTH + 1)) ++ # 167 * 3 = 501 which is LANGUAGE_CODE_MAX_LENGTH + 1. ++ self.assertEqual(g("en-" * 167), "en") ++ with self.assertRaisesMessage(ValueError, msg): ++ g("en-" * 167, strict=True) ++ self.assertEqual(g("en-" * 30000), "en") # catastrophic test + + def test_get_supported_language_variant_null(self): + g = trans_null.get_supported_language_variant diff -Nru python-django-3.2.19/debian/patches/0017-CVE-2024-39614-1.patch python-django-3.2.25/debian/patches/0017-CVE-2024-39614-1.patch --- python-django-3.2.19/debian/patches/0017-CVE-2024-39614-1.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0017-CVE-2024-39614-1.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,127 +0,0 @@ -commit 9e9792228a6bb5d6402a5d645bc3be4cf364aefb -Author: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> -Date: Wed Jun 26 12:11:54 2024 +0200 - - Fixed CVE-2024-39614 -- Mitigated potential DoS in get_supported_language_variant(). - - Language codes are now parsed with a maximum length limit of 500 chars. - - Thanks to MProgrammer for the report. - -diff --git a/django/utils/translation/trans_real.py b/django/utils/translation/trans_real.py -index b262a5000..92442185f 100644 ---- a/django/utils/translation/trans_real.py -+++ b/django/utils/translation/trans_real.py -@@ -31,9 +31,10 @@ _default = None - CONTEXT_SEPARATOR = "\x04" - - # Maximum number of characters that will be parsed from the Accept-Language --# header to prevent possible denial of service or memory exhaustion attacks. --# About 10x longer than the longest value shown on MDN’s Accept-Language page. --ACCEPT_LANGUAGE_HEADER_MAX_LENGTH = 500 -+# header or cookie to prevent possible denial of service or memory exhaustion -+# attacks. About 10x longer than the longest value shown on MDN’s -+# Accept-Language page. -+LANGUAGE_CODE_MAX_LENGTH = 500 - - # Format of Accept-Language header values. From RFC 2616, section 14.4 and 3.9 - # and RFC 3066, section 2.1 -@@ -474,11 +475,25 @@ def get_supported_language_variant(lang_code, strict=False): - If `strict` is False (the default), look for a country-specific variant - when neither the language code nor its generic variant is found. - -+ The language code is truncated to a maximum length to avoid potential -+ denial of service attacks. -+ - lru_cache should have a maxsize to prevent from memory exhaustion attacks, - as the provided language codes are taken from the HTTP request. See also - . - """ - if lang_code: -+ # Truncate the language code to a maximum length to avoid potential -+ # denial of service attacks. -+ if len(lang_code) > LANGUAGE_CODE_MAX_LENGTH: -+ if ( -+ not strict -+ and (index := lang_code.rfind("-", 0, LANGUAGE_CODE_MAX_LENGTH)) > 0 -+ ): -+ # There is a generic variant under the maximum length accepted length. -+ lang_code = lang_code[:index] -+ else: -+ raise ValueError("'lang_code' exceeds the maximum accepted length") - # If 'fr-ca' is not supported, try special fallback or language-only 'fr'. - possible_lang_codes = [lang_code] - try: -@@ -595,13 +610,13 @@ def parse_accept_lang_header(lang_string): - functools.lru_cache() to avoid repetitive parsing of common header values. - """ - # If the header value doesn't exceed the maximum allowed length, parse it. -- if len(lang_string) <= ACCEPT_LANGUAGE_HEADER_MAX_LENGTH: -+ if len(lang_string) <= LANGUAGE_CODE_MAX_LENGTH: - return _parse_accept_lang_header(lang_string) - - # If there is at least one comma in the value, parse up to the last comma - # before the max length, skipping any truncated parts at the end of the - # header value. -- index = lang_string.rfind(",", 0, ACCEPT_LANGUAGE_HEADER_MAX_LENGTH) -+ index = lang_string.rfind(",", 0, LANGUAGE_CODE_MAX_LENGTH) - if index > 0: - return _parse_accept_lang_header(lang_string[:index]) - -diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt -index ce3a4cba0..00a3f5e79 100644 ---- a/docs/ref/utils.txt -+++ b/docs/ref/utils.txt -@@ -1129,6 +1129,11 @@ For a complete discussion on the usage of the following see the - ``lang_code`` is ``'es-ar'`` and ``'es'`` is in :setting:`LANGUAGES` but - ``'es-ar'`` isn't. - -+ ``lang_code`` has a maximum accepted length of 500 characters. A -+ :exc:`ValueError` is raised if ``lang_code`` exceeds this limit and -+ ``strict`` is ``True``, or if there is no generic variant and ``strict`` -+ is ``False``. -+ - If ``strict`` is ``False`` (the default), a country-specific variant may - be returned when neither the language code nor its generic variant is found. - For example, if only ``'es-co'`` is in :setting:`LANGUAGES`, that's -@@ -1137,6 +1142,11 @@ For a complete discussion on the usage of the following see the - - Raises :exc:`LookupError` if nothing is found. - -+ .. versionchanged:: 4.2.14 -+ -+ In older versions, ``lang_code`` values over 500 characters were -+ processed without raising a :exc:`ValueError`. -+ - .. function:: to_locale(language) - - Turns a language name (en-us) into a locale name (en_US). -diff --git a/tests/i18n/tests.py b/tests/i18n/tests.py -index 41ec63da9..793bd3db8 100644 ---- a/tests/i18n/tests.py -+++ b/tests/i18n/tests.py -@@ -40,6 +40,7 @@ from django.utils.translation import ( - from django.utils.translation.reloader import ( - translation_file_changed, watch_for_translation_changes, - ) -+from django.utils.translation.trans_real import LANGUAGE_CODE_MAX_LENGTH - - from .forms import CompanyForm, I18nForm, SelectDateForm - from .models import Company, TestModel -@@ -1532,6 +1533,16 @@ class MiscTests(SimpleTestCase): - g('xyz') - with self.assertRaises(LookupError): - g('xy-zz') -+ msg = "'lang_code' exceeds the maximum accepted length" -+ with self.assertRaises(LookupError): -+ g("x" * LANGUAGE_CODE_MAX_LENGTH) -+ with self.assertRaisesMessage(ValueError, msg): -+ g("x" * (LANGUAGE_CODE_MAX_LENGTH + 1)) -+ # 167 * 3 = 501 which is LANGUAGE_CODE_MAX_LENGTH + 1. -+ self.assertEqual(g("en-" * 167), "en") -+ with self.assertRaisesMessage(ValueError, msg): -+ g("en-" * 167, strict=True) -+ self.assertEqual(g("en-" * 30000), "en") # catastrophic test - - def test_get_supported_language_variant_null(self): - g = trans_null.get_supported_language_variant diff -Nru python-django-3.2.19/debian/patches/0017-CVE-2024-41989.patch python-django-3.2.25/debian/patches/0017-CVE-2024-41989.patch --- python-django-3.2.19/debian/patches/0017-CVE-2024-41989.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0017-CVE-2024-41989.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,76 @@ +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Fri, 12 Jul 2024 11:38:34 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-41989 -- Prevented excessive memory + consumption in floatformat. MIME-Version: 1.0 Content-Type: text/plain; + charset=UTF-8 Content-Transfer-Encoding: 8bit +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Thanks Elias Myllymäki for the report. + +Co-authored-by: Shai Berger +--- + django/template/defaultfilters.py | 13 +++++++++++++ + tests/template_tests/filter_tests/test_floatformat.py | 17 +++++++++++++++++ + 2 files changed, 30 insertions(+) + +diff --git a/django/template/defaultfilters.py b/django/template/defaultfilters.py +index 92050122abdf..bc38d44b1d56 100644 +--- a/django/template/defaultfilters.py ++++ b/django/template/defaultfilters.py +@@ -146,6 +146,19 @@ def floatformat(text, arg=-1): + except ValueError: + return input_val + ++ _, digits, exponent = d.as_tuple() ++ try: ++ number_of_digits_and_exponent_sum = len(digits) + abs(exponent) ++ except TypeError: ++ # Exponent values can be "F", "n", "N". ++ number_of_digits_and_exponent_sum = 0 ++ ++ # Values with more than 200 digits, or with a large exponent, are returned "as is" ++ # to avoid high memory consumption and potential denial-of-service attacks. ++ # The cut-off of 200 is consistent with django.utils.numberformat.floatformat(). ++ if number_of_digits_and_exponent_sum > 200: ++ return input_val ++ + try: + m = int(d) - d + except (ValueError, OverflowError, InvalidOperation): +diff --git a/tests/template_tests/filter_tests/test_floatformat.py b/tests/template_tests/filter_tests/test_floatformat.py +index 64d65f7dc48c..e65663ed2bd9 100644 +--- a/tests/template_tests/filter_tests/test_floatformat.py ++++ b/tests/template_tests/filter_tests/test_floatformat.py +@@ -59,6 +59,7 @@ class FunctionTests(SimpleTestCase): + self.assertEqual(floatformat(1.5e-15, 20), '0.00000000000000150000') + self.assertEqual(floatformat(1.5e-15, -20), '0.00000000000000150000') + self.assertEqual(floatformat(1.00000000000000015, 16), '1.0000000000000002') ++ self.assertEqual(floatformat("1e199"), "1" + "0" * 199) + + @override_settings(USE_L10N=True) + def test_force_grouping(self): +@@ -95,6 +96,22 @@ class FunctionTests(SimpleTestCase): + self.assertEqual(floatformat(pos_inf), 'inf') + self.assertEqual(floatformat(neg_inf), '-inf') + self.assertEqual(floatformat(pos_inf / pos_inf), 'nan') ++ self.assertEqual(floatformat("inf").replace("'", ""), "inf") ++ self.assertEqual(floatformat("NaN").replace("'", ""), "NaN") ++ ++ def test_too_many_digits_to_render(self): ++ cases = [ ++ "1e200", ++ "1E200", ++ "1E10000000000000000", ++ "-1E10000000000000000", ++ "1e10000000000000000", ++ "-1e10000000000000000", ++ "1" + "0" * 1_000_000, ++ ] ++ for value in cases: ++ with self.subTest(value=value): ++ self.assertEqual(floatformat(value).replace("'", ""), value) + + def test_float_dunder_method(self): + class FloatWrapper: diff -Nru python-django-3.2.19/debian/patches/0018-CVE-2024-39614-2.patch python-django-3.2.25/debian/patches/0018-CVE-2024-39614-2.patch --- python-django-3.2.19/debian/patches/0018-CVE-2024-39614-2.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0018-CVE-2024-39614-2.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,92 +0,0 @@ -commit 0e94f292cda632153f2b3d9a9037eb0141ae9c2e -Author: Lorenzo Peña -Date: Tue Jul 23 12:06:29 2024 +0200 - - Fixed #35627 -- Raised a LookupError rather than an unhandled ValueError in get_supported_language_variant(). - - LocaleMiddleware didn't handle the ValueError raised by - get_supported_language_variant() when language codes were - over 500 characters. - - Regression in 9e9792228a6bb5d6402a5d645bc3be4cf364aefb. - -Index: python-django-debian.git/django/utils/translation/trans_real.py -=================================================================== ---- python-django-debian.git.orig/django/utils/translation/trans_real.py -+++ python-django-debian.git/django/utils/translation/trans_real.py -@@ -493,7 +493,7 @@ def get_supported_language_variant(lang_ - # There is a generic variant under the maximum length accepted length. - lang_code = lang_code[:index] - else: -- raise ValueError("'lang_code' exceeds the maximum accepted length") -+ raise LookupError(lang_code) - # If 'fr-ca' is not supported, try special fallback or language-only 'fr'. - possible_lang_codes = [lang_code] - try: -Index: python-django-debian.git/docs/ref/utils.txt -=================================================================== ---- python-django-debian.git.orig/docs/ref/utils.txt -+++ python-django-debian.git/docs/ref/utils.txt -@@ -1130,7 +1130,7 @@ For a complete discussion on the usage o - ``'es-ar'`` isn't. - - ``lang_code`` has a maximum accepted length of 500 characters. A -- :exc:`ValueError` is raised if ``lang_code`` exceeds this limit and -+ :exc:`LookupError` is raised if ``lang_code`` exceeds this limit and - ``strict`` is ``True``, or if there is no generic variant and ``strict`` - is ``False``. - -@@ -1142,10 +1142,10 @@ For a complete discussion on the usage o - - Raises :exc:`LookupError` if nothing is found. - -- .. versionchanged:: 4.2.14 -+ .. versionchanged:: 4.2.15 - - In older versions, ``lang_code`` values over 500 characters were -- processed without raising a :exc:`ValueError`. -+ processed without raising a :exc:`LookupError`. - - .. function:: to_locale(language) - -Index: python-django-debian.git/tests/i18n/tests.py -=================================================================== ---- python-django-debian.git.orig/tests/i18n/tests.py -+++ python-django-debian.git/tests/i18n/tests.py -@@ -1533,14 +1533,13 @@ class MiscTests(SimpleTestCase): - g('xyz') - with self.assertRaises(LookupError): - g('xy-zz') -- msg = "'lang_code' exceeds the maximum accepted length" - with self.assertRaises(LookupError): - g("x" * LANGUAGE_CODE_MAX_LENGTH) -- with self.assertRaisesMessage(ValueError, msg): -+ with self.assertRaises(LookupError): - g("x" * (LANGUAGE_CODE_MAX_LENGTH + 1)) - # 167 * 3 = 501 which is LANGUAGE_CODE_MAX_LENGTH + 1. - self.assertEqual(g("en-" * 167), "en") -- with self.assertRaisesMessage(ValueError, msg): -+ with self.assertRaises(LookupError): - g("en-" * 167, strict=True) - self.assertEqual(g("en-" * 30000), "en") # catastrophic test - -@@ -1579,6 +1578,7 @@ class MiscTests(SimpleTestCase): - self.assertEqual(g('/de-at/'), 'de-at') - self.assertEqual(g('/de-ch/'), 'de') - self.assertIsNone(g('/de-simple-page/')) -+ self.assertIsNone(g(f"/{'a' * 501}/")) - - def test_get_language_from_path_null(self): - g = trans_null.get_language_from_path -@@ -1866,6 +1866,11 @@ class CountrySpecificLanguageTests(Simpl - lang = get_language_from_request(r) - self.assertEqual('bg', lang) - -+ def test_get_language_from_request_code_too_long(self): -+ request = self.rf.get("/", headers={"accept-language": "a" * 501}) -+ lang = get_language_from_request(request) -+ self.assertEqual("en-us", lang) -+ - def test_get_language_from_request_null(self): - lang = trans_null.get_language_from_request(None) - self.assertEqual(lang, 'en') diff -Nru python-django-3.2.19/debian/patches/0018-CVE-2024-41991.patch python-django-3.2.25/debian/patches/0018-CVE-2024-41991.patch --- python-django-3.2.19/debian/patches/0018-CVE-2024-41991.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0018-CVE-2024-41991.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,113 @@ +From: Mariusz Felisiak +Date: Wed, 10 Jul 2024 20:30:12 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-41991 -- Prevented potential ReDoS in + django.utils.html.urlize() and AdminURLFieldWidget. + +Thanks Seokchan Yoon for the report. + +Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +--- + django/contrib/admin/widgets.py | 2 +- + django/utils/html.py | 10 ++++++++-- + tests/admin_widgets/tests.py | 7 ++++++- + tests/utils_tests/test_html.py | 13 +++++++++++++ + 4 files changed, 28 insertions(+), 4 deletions(-) + +diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py +index aeb74773ac64..b7dd0d87a0a6 100644 +--- a/django/contrib/admin/widgets.py ++++ b/django/contrib/admin/widgets.py +@@ -339,7 +339,7 @@ class AdminURLFieldWidget(forms.URLInput): + context = super().get_context(name, value, attrs) + context['current_label'] = _('Currently:') + context['change_label'] = _('Change:') +- context['widget']['href'] = smart_urlquote(context['widget']['value']) if value else '' ++ context['widget']['href'] = smart_urlquote(context['widget']['value']) if url_valid else '' + context['url_valid'] = url_valid + return context + +diff --git a/django/utils/html.py b/django/utils/html.py +index 3bc02b8dd3aa..44c6b7b81f4d 100644 +--- a/django/utils/html.py ++++ b/django/utils/html.py +@@ -29,6 +29,8 @@ simple_url_2_re = _lazy_re_compile( + re.IGNORECASE + ) + ++MAX_URL_LENGTH = 2048 ++ + + @keep_lazy(str, SafeString) + def escape(text): +@@ -298,6 +300,10 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): + except ValueError: + # value contains more than one @. + return False ++ # Max length for domain name labels is 63 characters per RFC 1034. ++ # Helps to avoid ReDoS vectors in the domain part. ++ if len(p2) > 63: ++ return False + # Dot must be in p2 (e.g. example.com) + if '.' not in p2 or p2.startswith('.'): + return False +@@ -316,9 +322,9 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): + # Make URL we want to point to. + url = None + nofollow_attr = ' rel="nofollow"' if nofollow else '' +- if simple_url_re.match(middle): ++ if len(middle) <= MAX_URL_LENGTH and simple_url_re.match(middle): + url = smart_urlquote(html.unescape(middle)) +- elif simple_url_2_re.match(middle): ++ elif len(middle) <= MAX_URL_LENGTH and simple_url_2_re.match(middle): + url = smart_urlquote('http://%s' % html.unescape(middle)) + elif ':' not in middle and is_email_simple(middle): + local, domain = middle.rsplit('@', 1) +diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py +index f701f1abff83..ef6167374d77 100644 +--- a/tests/admin_widgets/tests.py ++++ b/tests/admin_widgets/tests.py +@@ -353,7 +353,12 @@ class AdminSplitDateTimeWidgetTest(SimpleTestCase): + class AdminURLWidgetTest(SimpleTestCase): + def test_get_context_validates_url(self): + w = widgets.AdminURLFieldWidget() +- for invalid in ['', '/not/a/full/url/', 'javascript:alert("Danger XSS!")']: ++ for invalid in [ ++ '', ++ '/not/a/full/url/', ++ 'javascript:alert("Danger XSS!")', ++ "http://" + "한.글." * 1_000_000 + "com", ++ ]: + with self.subTest(url=invalid): + self.assertFalse(w.get_context('name', invalid, {})['url_valid']) + self.assertTrue(w.get_context('name', 'http://example.com', {})['url_valid']) +diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py +index 30f5ba68e862..93458acd1f06 100644 +--- a/tests/utils_tests/test_html.py ++++ b/tests/utils_tests/test_html.py +@@ -255,6 +255,15 @@ class TestUtilsHtml(SimpleTestCase): + 'Search for google.com/?q=!' + ), + ('foo@example.com', 'foo@example.com'), ++ ( ++ "test@" + "한.글." * 15 + "aaa", ++ '' ++ + "test@" ++ + "한.글." * 15 ++ + "aaa", ++ ), + ) + for value, output in tests: + with self.subTest(value=value): +@@ -263,6 +272,10 @@ class TestUtilsHtml(SimpleTestCase): + def test_urlize_unchanged_inputs(self): + tests = ( + ('a' + '@a' * 50000) + 'a', # simple_email_re catastrophic test ++ # Unicode domain catastrophic tests. ++ "a@" + "한.글." * 1_000_000 + "a", ++ "http://" + "한.글." * 1_000_000 + "com", ++ "www." + "한.글." * 1_000_000 + "com", + ('a' + '.' * 1000000) + 'a', # trailing_punctuation catastrophic test + 'foo@', + '@foo.com', diff -Nru python-django-3.2.19/debian/patches/0019-CVE-2024-41989.patch python-django-3.2.25/debian/patches/0019-CVE-2024-41989.patch --- python-django-3.2.19/debian/patches/0019-CVE-2024-41989.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0019-CVE-2024-41989.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,67 +0,0 @@ -commit c19465ad87e33b6122c886b97a202ad54cd43672 -Author: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> -Date: Fri Jul 12 11:38:34 2024 +0200 - - Fixed CVE-2024-41989 -- Prevented excessive memory consumption in floatformat. - - Thanks Elias Myllymäki for the report. - - Co-authored-by: Shai Berger - -diff --git a/django/template/defaultfilters.py b/django/template/defaultfilters.py -index 02cac06bcf..66c6e76d20 100644 ---- a/django/template/defaultfilters.py -+++ b/django/template/defaultfilters.py -@@ -146,6 +146,19 @@ def floatformat(text, arg=-1): - except ValueError: - return input_val - -+ _, digits, exponent = d.as_tuple() -+ try: -+ number_of_digits_and_exponent_sum = len(digits) + abs(exponent) -+ except TypeError: -+ # Exponent values can be "F", "n", "N". -+ number_of_digits_and_exponent_sum = 0 -+ -+ # Values with more than 200 digits, or with a large exponent, are returned "as is" -+ # to avoid high memory consumption and potential denial-of-service attacks. -+ # The cut-off of 200 is consistent with django.utils.numberformat.floatformat(). -+ if number_of_digits_and_exponent_sum > 200: -+ return input_val -+ - try: - m = int(d) - d - except (ValueError, OverflowError, InvalidOperation): -diff --git a/tests/template_tests/filter_tests/test_floatformat.py b/tests/template_tests/filter_tests/test_floatformat.py -index 145858b75f..3d6c34a552 100644 ---- a/tests/template_tests/filter_tests/test_floatformat.py -+++ b/tests/template_tests/filter_tests/test_floatformat.py -@@ -59,6 +59,7 @@ class FunctionTests(SimpleTestCase): - self.assertEqual(floatformat(1.5e-15, 20), '0.00000000000000150000') - self.assertEqual(floatformat(1.5e-15, -20), '0.00000000000000150000') - self.assertEqual(floatformat(1.00000000000000015, 16), '1.0000000000000002') -+ self.assertEqual(floatformat("1e199"), "1" + "0" * 199) - - @override_settings(USE_L10N=True) - def test_force_grouping(self): -@@ -96,6 +97,20 @@ class FunctionTests(SimpleTestCase): - self.assertEqual(floatformat(pos_inf), 'inf') - self.assertEqual(floatformat(neg_inf), '-inf') - self.assertEqual(floatformat(pos_inf / pos_inf), 'nan') -+ -+ def test_too_many_digits_to_render(self): -+ cases = [ -+ "1e200", -+ "1E200", -+ "1E10000000000000000", -+ "-1E10000000000000000", -+ "1e10000000000000000", -+ "-1e10000000000000000", -+ "1" + "0" * 1_000_000, -+ ] -+ for value in cases: -+ with self.subTest(value=value): -+ self.assertEqual(floatformat(value), "'" + value + "'") - - def test_float_dunder_method(self): - class FloatWrapper: diff -Nru python-django-3.2.19/debian/patches/0019-CVE-2024-42005.patch python-django-3.2.25/debian/patches/0019-CVE-2024-42005.patch --- python-django-3.2.19/debian/patches/0019-CVE-2024-42005.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0019-CVE-2024-42005.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,74 @@ +From: Simon Charette +Date: Thu, 25 Jul 2024 18:19:13 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-42005 -- Mitigated QuerySet.values() + SQL injection attacks against JSON fields. + +Thanks Eyal (eyalgabay) for the report. +--- + django/db/models/sql/query.py | 2 ++ + tests/expressions/models.py | 7 +++++++ + tests/expressions/test_queryset_values.py | 17 +++++++++++++++-- + 3 files changed, 24 insertions(+), 2 deletions(-) + +diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py +index 817e0bb7ffc7..56afcda64f8b 100644 +--- a/django/db/models/sql/query.py ++++ b/django/db/models/sql/query.py +@@ -2239,6 +2239,8 @@ class Query(BaseExpression): + self.clear_select_fields() + + if fields: ++ for field in fields: ++ self.check_alias(field) + field_names = [] + extra_names = [] + annotation_names = [] +diff --git a/tests/expressions/models.py b/tests/expressions/models.py +index bd6be81c5f16..01d1699d4879 100644 +--- a/tests/expressions/models.py ++++ b/tests/expressions/models.py +@@ -101,3 +101,10 @@ class UUIDPK(models.Model): + class UUID(models.Model): + uuid = models.UUIDField(null=True) + uuid_fk = models.ForeignKey(UUIDPK, models.CASCADE, null=True) ++ ++ ++class JSONFieldModel(models.Model): ++ data = models.JSONField(null=True) ++ ++ class Meta: ++ required_db_features = {"supports_json_field"} +diff --git a/tests/expressions/test_queryset_values.py b/tests/expressions/test_queryset_values.py +index 457a357d041b..2b524d8d99fe 100644 +--- a/tests/expressions/test_queryset_values.py ++++ b/tests/expressions/test_queryset_values.py +@@ -1,7 +1,7 @@ + from django.db.models import F, Sum +-from django.test import TestCase ++from django.test import TestCase, skipUnlessDBFeature + +-from .models import Company, Employee ++from .models import Company, Employee, JSONFieldModel + + + class ValuesExpressionsTests(TestCase): +@@ -35,6 +35,19 @@ class ValuesExpressionsTests(TestCase): + 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, quotation marks, " ++ "semicolons, or SQL comments." ++ ) ++ 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}") ++ + def test_values_expression_group_by(self): + # values() applies annotate() first, so values selected are grouped by + # id, not firstname. diff -Nru python-django-3.2.19/debian/patches/0020-CVE-2024-41991.patch python-django-3.2.25/debian/patches/0020-CVE-2024-41991.patch --- python-django-3.2.19/debian/patches/0020-CVE-2024-41991.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0020-CVE-2024-41991.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,108 +0,0 @@ -commit 5f1757142febd95994caa1c0f64c1a0c161982c3 -Author: Mariusz Felisiak -Date: Wed Jul 10 20:30:12 2024 +0200 - - Fixed CVE-2024-41991 -- Prevented potential ReDoS in django.utils.html.urlize() and AdminURLFieldWidget. - - Thanks Seokchan Yoon for the report. - - Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> - -diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py -index aeb74773a..b7dd0d87a 100644 ---- a/django/contrib/admin/widgets.py -+++ b/django/contrib/admin/widgets.py -@@ -339,7 +339,7 @@ class AdminURLFieldWidget(forms.URLInput): - context = super().get_context(name, value, attrs) - context['current_label'] = _('Currently:') - context['change_label'] = _('Change:') -- context['widget']['href'] = smart_urlquote(context['widget']['value']) if value else '' -+ context['widget']['href'] = smart_urlquote(context['widget']['value']) if url_valid else '' - context['url_valid'] = url_valid - return context - -diff --git a/django/utils/html.py b/django/utils/html.py -index 1123e38f6c..154c820d34 100644 ---- a/django/utils/html.py -+++ b/django/utils/html.py -@@ -28,7 +28,7 @@ simple_url_2_re = _lazy_re_compile( - r'^www\.|^(?!http)\w[^@]+\.(com|edu|gov|int|mil|net|org)($|/.*)$', - re.IGNORECASE - ) -- -+MAX_URL_LENGTH = 2048 - - @keep_lazy(str, SafeString) - def escape(text): -@@ -298,6 +298,10 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): - except ValueError: - # value contains more than one @. - return False -+ # Max length for domain name labels is 63 characters per RFC 1034. -+ # Helps to avoid ReDoS vectors in the domain part. -+ if len(p2) > 63: -+ return False - # Dot must be in p2 (e.g. example.com) - if '.' not in p2 or p2.startswith('.'): - return False -@@ -316,9 +322,9 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): - # Make URL we want to point to. - url = None - nofollow_attr = ' rel="nofollow"' if nofollow else '' -- if simple_url_re.match(middle): -+ if len(middle) <= MAX_URL_LENGTH and simple_url_re.match(middle): - url = smart_urlquote(html.unescape(middle)) -- elif simple_url_2_re.match(middle): -+ elif len(middle) <= MAX_URL_LENGTH and simple_url_2_re.match(middle): - url = smart_urlquote('http://%s' % html.unescape(middle)) - elif ':' not in middle and is_email_simple(middle): - local, domain = middle.rsplit('@', 1) -diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py -index 517e060b80..5da4adf8c9 100644 ---- a/tests/admin_widgets/tests.py -+++ b/tests/admin_widgets/tests.py -@@ -353,7 +353,12 @@ class AdminSplitDateTimeWidgetTest(SimpleTestCase): - class AdminURLWidgetTest(SimpleTestCase): - def test_get_context_validates_url(self): - w = widgets.AdminURLFieldWidget() -- for invalid in ['', '/not/a/full/url/', 'javascript:alert("Danger XSS!")']: -+ for invalid in [ -+ "", -+ "/not/a/full/url/", -+ 'javascript:alert("Danger XSS!")', -+ "http://" + "한.글." * 1_000_000 + "com", -+ ]: - with self.subTest(url=invalid): - self.assertFalse(w.get_context('name', invalid, {})['url_valid']) - self.assertTrue(w.get_context('name', 'http://example.com', {})['url_valid']) -diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py -index 30f5ba68e..93458acd1 100644 ---- a/tests/utils_tests/test_html.py -+++ b/tests/utils_tests/test_html.py -@@ -255,6 +255,15 @@ class TestUtilsHtml(SimpleTestCase): - 'Search for google.com/?q=!' - ), - ('foo@example.com', 'foo@example.com'), -+ ( -+ "test@" + "한.글." * 15 + "aaa", -+ '' -+ + "test@" -+ + "한.글." * 15 -+ + "aaa", -+ ), - ) - for value, output in tests: - with self.subTest(value=value): -@@ -263,6 +272,10 @@ class TestUtilsHtml(SimpleTestCase): - def test_urlize_unchanged_inputs(self): - tests = ( - ('a' + '@a' * 50000) + 'a', # simple_email_re catastrophic test -+ # Unicode domain catastrophic tests. -+ "a@" + "한.글." * 1_000_000 + "a", -+ "http://" + "한.글." * 1_000_000 + "com", -+ "www." + "한.글." * 1_000_000 + "com", - ('a' + '.' * 1000000) + 'a', # trailing_punctuation catastrophic test - 'foo@', - '@foo.com', diff -Nru python-django-3.2.19/debian/patches/0020-CVE-2024-45231.patch python-django-3.2.25/debian/patches/0020-CVE-2024-45231.patch --- python-django-3.2.19/debian/patches/0020-CVE-2024-45231.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0020-CVE-2024-45231.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,111 @@ +From: Natalia <124304+nessita@users.noreply.github.com> +Date: Mon, 19 Aug 2024 14:47:38 -0300 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-45231 -- Avoided server error on + password reset when email sending fails. + +On successful submission of a password reset request, an email is sent +to the accounts known to the system. If sending this email fails (due to +email backend misconfiguration, service provider outage, network issues, +etc.), an attacker might exploit this by detecting which password reset +requests succeed and which ones generate a 500 error response. + +Thanks to Thibaut Spriet for the report, and to Mariusz Felisiak, Adam +Johnson, and Sarah Boyce for the reviews. +--- + django/contrib/auth/forms.py | 9 ++++++++- + docs/topics/auth/default.txt | 4 +++- + tests/auth_tests/test_forms.py | 21 +++++++++++++++++++++ + tests/mail/custombackend.py | 5 +++++ + 4 files changed, 37 insertions(+), 2 deletions(-) + +diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py +index fb7cfda2098d..85d82de9da15 100644 +--- a/django/contrib/auth/forms.py ++++ b/django/contrib/auth/forms.py +@@ -1,3 +1,4 @@ ++import logging + import unicodedata + + from django import forms +@@ -19,6 +20,7 @@ from django.utils.text import capfirst + from django.utils.translation import gettext, gettext_lazy as _ + + UserModel = get_user_model() ++logger = logging.getLogger("django.contrib.auth") + + + def _unicode_ci_compare(s1, s2): +@@ -265,7 +267,12 @@ class PasswordResetForm(forms.Form): + html_email = loader.render_to_string(html_email_template_name, context) + email_message.attach_alternative(html_email, 'text/html') + +- email_message.send() ++ try: ++ email_message.send() ++ except Exception: ++ logger.exception( ++ "Failed to send password reset email to %s", context["user"].pk ++ ) + + def get_users(self, email): + """Given an email, return matching user(s) who should receive a reset. +diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt +index 343c44084eb8..87bb98cbfb22 100644 +--- a/docs/topics/auth/default.txt ++++ b/docs/topics/auth/default.txt +@@ -1526,7 +1526,9 @@ provides several built-in forms located in :mod:`django.contrib.auth.forms`: + .. method:: send_mail(subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name=None) + + Uses the arguments to send an ``EmailMultiAlternatives``. +- Can be overridden to customize how the email is sent to the user. ++ Can be overridden to customize how the email is sent to the user. If ++ you choose to override this method, be mindful of handling potential ++ exceptions raised due to email sending failures. + + :param subject_template_name: the template for the subject. + :param email_template_name: the template for the email body. +diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py +index c0e1975c1a98..86b7a5c88192 100644 +--- a/tests/auth_tests/test_forms.py ++++ b/tests/auth_tests/test_forms.py +@@ -985,6 +985,27 @@ class PasswordResetFormTest(TestDataMixin, TestCase): + message.get_payload(1).get_payload() + )) + ++ @override_settings(EMAIL_BACKEND="mail.custombackend.FailingEmailBackend") ++ def test_save_send_email_exceptions_are_catched_and_logged(self): ++ (user, username, email) = self.create_dummy_user() ++ form = PasswordResetForm({"email": email}) ++ self.assertTrue(form.is_valid()) ++ ++ with self.assertLogs("django.contrib.auth", level=0) as cm: ++ form.save() ++ ++ self.assertEqual(len(mail.outbox), 0) ++ self.assertEqual(len(cm.output), 1) ++ errors = cm.output[0].split("\n") ++ pk = user.pk ++ self.assertEqual( ++ errors[0], ++ f"ERROR:django.contrib.auth:Failed to send password reset email to {pk}", ++ ) ++ self.assertEqual( ++ errors[-1], "ValueError: FailingEmailBackend is doomed to fail." ++ ) ++ + @override_settings(AUTH_USER_MODEL='auth_tests.CustomEmailField') + def test_custom_email_field(self): + email = 'test@mail.com' +diff --git a/tests/mail/custombackend.py b/tests/mail/custombackend.py +index fd57777787c7..3e161d1b51a5 100644 +--- a/tests/mail/custombackend.py ++++ b/tests/mail/custombackend.py +@@ -13,3 +13,8 @@ class EmailBackend(BaseEmailBackend): + # Messages are stored in an instance variable for testing. + self.test_outbox.extend(email_messages) + return len(email_messages) ++ ++ ++class FailingEmailBackend(BaseEmailBackend): ++ def send_messages(self, email_messages): ++ raise ValueError("FailingEmailBackend is doomed to fail.") diff -Nru python-django-3.2.19/debian/patches/0021-CVE-2024-42005.patch python-django-3.2.25/debian/patches/0021-CVE-2024-42005.patch --- python-django-3.2.19/debian/patches/0021-CVE-2024-42005.patch 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/0021-CVE-2024-42005.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,70 +0,0 @@ -commit c87bfaacf8fb84984243b5055dc70f97996cb115 -Author: Simon Charette -Date: Thu Jul 25 12:19:13 2024 -0400 - - Fixed CVE-2024-42005 -- Mitigated QuerySet.values() SQL injection attacks against JSON fields. - - Thanks Eyal (eyalgabay) for the report. - -Index: python-django-debian.git/django/db/models/sql/query.py -=================================================================== ---- python-django-debian.git.orig/django/db/models/sql/query.py -+++ python-django-debian.git/django/db/models/sql/query.py -@@ -2239,6 +2239,8 @@ class Query(BaseExpression): - self.clear_select_fields() - - if fields: -+ for field in fields: -+ self.check_alias(field) - field_names = [] - extra_names = [] - annotation_names = [] -Index: python-django-debian.git/tests/expressions/models.py -=================================================================== ---- python-django-debian.git.orig/tests/expressions/models.py -+++ python-django-debian.git/tests/expressions/models.py -@@ -101,3 +101,10 @@ class UUIDPK(models.Model): - class UUID(models.Model): - uuid = models.UUIDField(null=True) - uuid_fk = models.ForeignKey(UUIDPK, models.CASCADE, null=True) -+ -+ -+class JSONFieldModel(models.Model): -+ data = models.JSONField(null=True) -+ -+ class Meta: -+ required_db_features = {"supports_json_field"} -Index: python-django-debian.git/tests/expressions/test_queryset_values.py -=================================================================== ---- python-django-debian.git.orig/tests/expressions/test_queryset_values.py -+++ python-django-debian.git/tests/expressions/test_queryset_values.py -@@ -1,7 +1,7 @@ - from django.db.models import F, Sum --from django.test import TestCase -+from django.test import TestCase, skipUnlessDBFeature - --from .models import Company, Employee -+from .models import Company, Employee, JSONFieldModel - - - class ValuesExpressionsTests(TestCase): -@@ -35,6 +35,19 @@ class ValuesExpressionsTests(TestCase): - 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, quotation marks, " -+ "semicolons, or SQL comments." -+ ) -+ 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}") -+ - def test_values_expression_group_by(self): - # values() applies annotate() first, so values selected are grouped by - # id, not firstname. diff -Nru python-django-3.2.19/debian/patches/0021-CVE-2024-53907.patch python-django-3.2.25/debian/patches/0021-CVE-2024-53907.patch --- python-django-3.2.19/debian/patches/0021-CVE-2024-53907.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0021-CVE-2024-53907.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,85 @@ +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Wed, 13 Nov 2024 15:06:23 +0100 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-53907 -- Mitigated potential DoS in + strip_tags(). + +Thanks to jiangniao for the report, and Shai Berger and Natalia Bidart +for the reviews. +--- + django/utils/html.py | 10 ++++++++-- + tests/utils_tests/test_html.py | 7 +++++++ + 2 files changed, 15 insertions(+), 2 deletions(-) + +diff --git a/django/utils/html.py b/django/utils/html.py +index 44c6b7b81f4d..5887bf1b4d7d 100644 +--- a/django/utils/html.py ++++ b/django/utils/html.py +@@ -8,6 +8,7 @@ from urllib.parse import ( + parse_qsl, quote, unquote, urlencode, urlsplit, urlunsplit, + ) + ++from django.core.exceptions import SuspiciousOperation + from django.utils.encoding import punycode + from django.utils.functional import Promise, keep_lazy, keep_lazy_text + from django.utils.http import RFC3986_GENDELIMS, RFC3986_SUBDELIMS +@@ -30,6 +31,7 @@ simple_url_2_re = _lazy_re_compile( + ) + + MAX_URL_LENGTH = 2048 ++MAX_STRIP_TAGS_DEPTH = 50 + + + @keep_lazy(str, SafeString) +@@ -181,15 +183,19 @@ def _strip_once(value): + @keep_lazy_text + def strip_tags(value): + """Return the given HTML with all tags stripped.""" +- # Note: in typical case this loop executes _strip_once once. Loop condition +- # is redundant, but helps to reduce number of executions of _strip_once. + value = str(value) ++ # Note: in typical case this loop executes _strip_once twice (the second ++ # execution does not remove any more tags). ++ strip_tags_depth = 0 + while '<' in value and '>' in value: ++ if strip_tags_depth >= MAX_STRIP_TAGS_DEPTH: ++ raise SuspiciousOperation + new_value = _strip_once(value) + if value.count('<') == new_value.count('<'): + # _strip_once wasn't able to detect more tags. + break + value = new_value ++ strip_tags_depth += 1 + return value + + +diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py +index 93458acd1f06..231f5d8f96bc 100644 +--- a/tests/utils_tests/test_html.py ++++ b/tests/utils_tests/test_html.py +@@ -1,6 +1,7 @@ + import os + from datetime import datetime + ++from django.core.exceptions import SuspiciousOperation + from django.test import SimpleTestCase + from django.utils.functional import lazystr + from django.utils.html import ( +@@ -92,12 +93,18 @@ class TestUtilsHtml(SimpleTestCase): + ('&h', 'alert()h'), + ('>br>br>br>X', 'XX'), ++ ("<" * 50 + "a>" * 50, ""), + ) + for value, output in items: + with self.subTest(value=value, output=output): + self.check_output(strip_tags, value, output) + self.check_output(strip_tags, lazystr(value), output) + ++ def test_strip_tags_suspicious_operation(self): ++ value = "<" * 51 + "a>" * 51, "" ++ with self.assertRaises(SuspiciousOperation): ++ strip_tags(value) ++ + def test_strip_tags_files(self): + # Test with more lengthy content (also catching performance regressions) + for filename in ('strip_tags1.html', 'strip_tags2.txt'): diff -Nru python-django-3.2.19/debian/patches/0022-CVE-2024-56374.patch python-django-3.2.25/debian/patches/0022-CVE-2024-56374.patch --- python-django-3.2.19/debian/patches/0022-CVE-2024-56374.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0022-CVE-2024-56374.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,258 @@ +From: Natalia <124304+nessita@users.noreply.github.com> +Date: Mon, 6 Jan 2025 15:51:45 -0300 +Subject: [PATCH] [4.2.x] Fixed CVE-2024-56374 -- Mitigated potential DoS in + IPv6 validation. + +Thanks Saravana Kumar for the report, and Sarah Boyce and Mariusz +Felisiak for the reviews. + +Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> +--- + django/db/models/fields/__init__.py | 6 ++-- + django/forms/fields.py | 7 ++-- + django/utils/ipv6.py | 16 +++++++-- + docs/ref/forms/fields.txt | 4 +-- + .../field_tests/test_genericipaddressfield.py | 32 ++++++++++++++++- + tests/utils_tests/test_ipv6.py | 40 ++++++++++++++++++++-- + 6 files changed, 91 insertions(+), 14 deletions(-) + +diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py +index 167c3d2f030f..5b439601a5cb 100644 +--- a/django/db/models/fields/__init__.py ++++ b/django/db/models/fields/__init__.py +@@ -22,7 +22,7 @@ from django.utils.dateparse import ( + ) + from django.utils.duration import duration_microseconds, duration_string + from django.utils.functional import Promise, cached_property +-from django.utils.ipv6 import clean_ipv6_address ++from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH, clean_ipv6_address + from django.utils.itercompat import is_iterable + from django.utils.text import capfirst + from django.utils.translation import gettext_lazy as _ +@@ -1913,7 +1913,7 @@ class GenericIPAddressField(Field): + self.default_validators, invalid_error_message = \ + validators.ip_address_validators(protocol, unpack_ipv4) + self.default_error_messages['invalid'] = invalid_error_message +- kwargs['max_length'] = 39 ++ kwargs['max_length'] = MAX_IPV6_ADDRESS_LENGTH + super().__init__(verbose_name, name, *args, **kwargs) + + def check(self, **kwargs): +@@ -1940,7 +1940,7 @@ class GenericIPAddressField(Field): + kwargs['unpack_ipv4'] = self.unpack_ipv4 + if self.protocol != "both": + kwargs['protocol'] = self.protocol +- if kwargs.get("max_length") == 39: ++ if kwargs.get("max_length") == MAX_IPV6_ADDRESS_LENGTH: + del kwargs['max_length'] + return name, path, args, kwargs + +diff --git a/django/forms/fields.py b/django/forms/fields.py +index 8adb09e38294..6969c4a9d051 100644 +--- a/django/forms/fields.py ++++ b/django/forms/fields.py +@@ -28,7 +28,7 @@ from django.forms.widgets import ( + from django.utils import formats + from django.utils.dateparse import parse_datetime, parse_duration + from django.utils.duration import duration_string +-from django.utils.ipv6 import clean_ipv6_address ++from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH, clean_ipv6_address + from django.utils.regex_helper import _lazy_re_compile + from django.utils.translation import gettext_lazy as _, ngettext_lazy + +@@ -1179,6 +1179,7 @@ class GenericIPAddressField(CharField): + def __init__(self, *, protocol='both', unpack_ipv4=False, **kwargs): + self.unpack_ipv4 = unpack_ipv4 + self.default_validators = validators.ip_address_validators(protocol, unpack_ipv4)[0] ++ kwargs.setdefault("max_length", MAX_IPV6_ADDRESS_LENGTH) + super().__init__(**kwargs) + + def to_python(self, value): +@@ -1186,7 +1187,9 @@ class GenericIPAddressField(CharField): + return '' + value = value.strip() + if value and ':' in value: +- return clean_ipv6_address(value, self.unpack_ipv4) ++ return clean_ipv6_address( ++ value, self.unpack_ipv4, max_length=self.max_length ++ ) + return value + + +diff --git a/django/utils/ipv6.py b/django/utils/ipv6.py +index ddb8c8091d2f..ef88f5263659 100644 +--- a/django/utils/ipv6.py ++++ b/django/utils/ipv6.py +@@ -3,9 +3,19 @@ import ipaddress + from django.core.exceptions import ValidationError + from django.utils.translation import gettext_lazy as _ + ++MAX_IPV6_ADDRESS_LENGTH = 39 ++ ++ ++def _ipv6_address_from_str(ip_str, max_length=MAX_IPV6_ADDRESS_LENGTH): ++ if len(ip_str) > max_length: ++ raise ValueError( ++ f"Unable to convert {ip_str} to an IPv6 address (value too long)." ++ ) ++ return ipaddress.IPv6Address(int(ipaddress.IPv6Address(ip_str))) ++ + + def clean_ipv6_address(ip_str, unpack_ipv4=False, +- error_message=_("This is not a valid IPv6 address.")): ++ error_message=_("This is not a valid IPv6 address."), max_length=MAX_IPV6_ADDRESS_LENGTH): + """ + Clean an IPv6 address string. + +@@ -23,7 +33,7 @@ def clean_ipv6_address(ip_str, unpack_ipv4=False, + Return a compressed IPv6 address or the same value. + """ + try: +- addr = ipaddress.IPv6Address(int(ipaddress.IPv6Address(ip_str))) ++ addr = _ipv6_address_from_str(ip_str, max_length) + except ValueError: + raise ValidationError(error_message, code='invalid') + +@@ -40,7 +50,7 @@ def is_valid_ipv6_address(ip_str): + Return whether or not the `ip_str` string is a valid IPv6 address. + """ + try: +- ipaddress.IPv6Address(ip_str) ++ _ipv6_address_from_str(ip_str) + except ValueError: + return False + return True +diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt +index 5b485f215384..9e0915894b5d 100644 +--- a/docs/ref/forms/fields.txt ++++ b/docs/ref/forms/fields.txt +@@ -847,7 +847,7 @@ For each field, we describe the default widget used if you don't specify + * Empty value: ``''`` (an empty string) + * Normalizes to: A string. IPv6 addresses are normalized as described below. + * Validates that the given value is a valid IP address. +- * Error message keys: ``required``, ``invalid`` ++ * Error message keys: ``required``, ``invalid``, ``max_length`` + + The IPv6 address normalization follows :rfc:`4291#section-2.2` section 2.2, + including using the IPv4 format suggested in paragraph 3 of that section, like +@@ -855,7 +855,7 @@ For each field, we describe the default widget used if you don't specify + ``2001::1``, and ``::ffff:0a0a:0a0a`` to ``::ffff:10.10.10.10``. All characters + are converted to lowercase. + +- Takes two optional arguments: ++ Takes three optional arguments: + + .. attribute:: protocol + +diff --git a/tests/forms_tests/field_tests/test_genericipaddressfield.py b/tests/forms_tests/field_tests/test_genericipaddressfield.py +index 92dbd71a284b..88eddb2448bf 100644 +--- a/tests/forms_tests/field_tests/test_genericipaddressfield.py ++++ b/tests/forms_tests/field_tests/test_genericipaddressfield.py +@@ -1,6 +1,7 @@ + from django.core.exceptions import ValidationError + from django.forms import GenericIPAddressField + from django.test import SimpleTestCase ++from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH + + + class GenericIPAddressFieldTest(SimpleTestCase): +@@ -90,6 +91,35 @@ class GenericIPAddressFieldTest(SimpleTestCase): + with self.assertRaisesMessage(ValidationError, "'This is not a valid IPv6 address.'"): + f.clean('1:2') + ++ def test_generic_ipaddress_max_length_custom(self): ++ # Valid IPv4-mapped IPv6 address, len 45. ++ addr = "0000:0000:0000:0000:0000:ffff:192.168.100.228" ++ f = GenericIPAddressField(max_length=len(addr)) ++ f.clean(addr) ++ ++ def test_generic_ipaddress_max_length_validation_error(self): ++ # Valid IPv4-mapped IPv6 address, len 45. ++ addr = "0000:0000:0000:0000:0000:ffff:192.168.100.228" ++ ++ cases = [ ++ ({}, MAX_IPV6_ADDRESS_LENGTH), # Default value. ++ ({"max_length": len(addr) - 1}, len(addr) - 1), ++ ] ++ for kwargs, max_length in cases: ++ max_length_plus_one = max_length + 1 ++ msg = ( ++ f"Ensure this value has at most {max_length} characters (it has " ++ f"{max_length_plus_one}).'" ++ ) ++ with self.subTest(max_length=max_length): ++ f = GenericIPAddressField(**kwargs) ++ with self.assertRaisesMessage(ValidationError, msg): ++ f.clean("x" * max_length_plus_one) ++ with self.assertRaisesMessage( ++ ValidationError, "This is not a valid IPv6 address." ++ ): ++ f.clean(addr) ++ + def test_generic_ipaddress_as_generic_not_required(self): + f = GenericIPAddressField(required=False) + self.assertEqual(f.clean(''), '') +@@ -104,7 +134,7 @@ class GenericIPAddressFieldTest(SimpleTestCase): + with self.assertRaisesMessage(ValidationError, "'Enter a valid IPv4 or IPv6 address.'"): + f.clean('256.125.1.5') + self.assertEqual(f.clean(' fe80::223:6cff:fe8a:2e8a '), 'fe80::223:6cff:fe8a:2e8a') +- self.assertEqual(f.clean(' 2a02::223:6cff:fe8a:2e8a '), '2a02::223:6cff:fe8a:2e8a') ++ self.assertEqual(f.clean(" " * MAX_IPV6_ADDRESS_LENGTH + ' 2a02::223:6cff:fe8a:2e8a '), '2a02::223:6cff:fe8a:2e8a') + with self.assertRaisesMessage(ValidationError, "'This is not a valid IPv6 address.'"): + f.clean('12345:2:3:4') + with self.assertRaisesMessage(ValidationError, "'This is not a valid IPv6 address.'"): +diff --git a/tests/utils_tests/test_ipv6.py b/tests/utils_tests/test_ipv6.py +index 4e434f3c3aa0..1ac6763d9b93 100644 +--- a/tests/utils_tests/test_ipv6.py ++++ b/tests/utils_tests/test_ipv6.py +@@ -1,9 +1,17 @@ +-import unittest ++import traceback ++from io import StringIO + +-from django.utils.ipv6 import clean_ipv6_address, is_valid_ipv6_address ++from django.core.exceptions import ValidationError ++from django.test import SimpleTestCase ++from django.utils.ipv6 import ( ++ MAX_IPV6_ADDRESS_LENGTH, ++ clean_ipv6_address, ++ is_valid_ipv6_address, ++) ++from django.utils.version import PY310 + + +-class TestUtilsIPv6(unittest.TestCase): ++class TestUtilsIPv6(SimpleTestCase): + + def test_validates_correct_plain_address(self): + self.assertTrue(is_valid_ipv6_address('fe80::223:6cff:fe8a:2e8a')) +@@ -55,3 +63,29 @@ class TestUtilsIPv6(unittest.TestCase): + self.assertEqual(clean_ipv6_address('::ffff:0a0a:0a0a', unpack_ipv4=True), '10.10.10.10') + self.assertEqual(clean_ipv6_address('::ffff:1234:1234', unpack_ipv4=True), '18.52.18.52') + self.assertEqual(clean_ipv6_address('::ffff:18.52.18.52', unpack_ipv4=True), '18.52.18.52') ++ ++ def test_address_too_long(self): ++ addresses = [ ++ "0000:0000:0000:0000:0000:ffff:192.168.100.228", # IPv4-mapped IPv6 address ++ "0000:0000:0000:0000:0000:ffff:192.168.100.228%123456", # % scope/zone ++ "fe80::223:6cff:fe8a:2e8a:1234:5678:00000", # MAX_IPV6_ADDRESS_LENGTH + 1 ++ ] ++ msg = "This is the error message." ++ value_error_msg = "Unable to convert %s to an IPv6 address (value too long)." ++ for addr in addresses: ++ with self.subTest(addr=addr): ++ self.assertGreater(len(addr), MAX_IPV6_ADDRESS_LENGTH) ++ self.assertEqual(is_valid_ipv6_address(addr), False) ++ with self.assertRaisesMessage(ValidationError, msg) as ctx: ++ clean_ipv6_address(addr, error_message=msg) ++ exception_traceback = StringIO() ++ if PY310: ++ traceback.print_exception(ctx.exception, file=exception_traceback) ++ else: ++ traceback.print_exception( ++ type(ctx.exception), ++ value=ctx.exception, ++ tb=ctx.exception.__traceback__, ++ file=exception_traceback, ++ ) ++ self.assertIn(value_error_msg % addr, exception_traceback.getvalue()) diff -Nru python-django-3.2.19/debian/patches/0023-CVE-2025-13372.patch python-django-3.2.25/debian/patches/0023-CVE-2025-13372.patch --- python-django-3.2.19/debian/patches/0023-CVE-2025-13372.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0023-CVE-2025-13372.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,92 @@ +From: Jacob Walls +Date: Mon, 17 Nov 2025 17:09:54 -0500 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-13372 -- Protected FilteredRelation + against SQL injection in column aliases on PostgreSQL. + +Follow-up to CVE-2025-57833. + +Thanks Stackered for the report, and Simon Charette and Mariusz Felisiak +for the reviews. + +Backport of 5b90ca1e7591fa36fccf2d6dad67cf1477e6293e from main. +--- + django/db/backends/postgresql/compiler.py | 24 ++++++++++++++++++++++++ + django/db/backends/postgresql/operations.py | 1 + + tests/annotations/tests.py | 13 +++++++++++++ + 3 files changed, 38 insertions(+) + create mode 100644 django/db/backends/postgresql/compiler.py + +diff --git a/django/db/backends/postgresql/compiler.py b/django/db/backends/postgresql/compiler.py +new file mode 100644 +index 000000000000..d4140c7f98a6 +--- /dev/null ++++ b/django/db/backends/postgresql/compiler.py +@@ -0,0 +1,24 @@ ++from django.db.models.sql.compiler import ( # isort:skip ++ SQLAggregateCompiler, ++ SQLCompiler as BaseSQLCompiler, ++ SQLDeleteCompiler, ++ SQLInsertCompiler, ++ SQLUpdateCompiler, ++) ++ ++__all__ = [ ++ "SQLAggregateCompiler", ++ "SQLCompiler", ++ "SQLDeleteCompiler", ++ "SQLInsertCompiler", ++ "SQLUpdateCompiler", ++] ++ ++ ++class SQLCompiler(BaseSQLCompiler): ++ def quote_name_unless_alias(self, name): ++ if "$" in name: ++ raise ValueError( ++ "Dollar signs are not permitted in column aliases on PostgreSQL." ++ ) ++ return super().quote_name_unless_alias(name) +diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py +index 0ef622efa869..1ffc9852e34d 100644 +--- a/django/db/backends/postgresql/operations.py ++++ b/django/db/backends/postgresql/operations.py +@@ -5,6 +5,7 @@ from django.db.backends.base.operations import BaseDatabaseOperations + + + class DatabaseOperations(BaseDatabaseOperations): ++ compiler_module = "django.db.backends.postgresql.compiler" + cast_char_field_without_max_length = 'varchar' + explain_prefix = 'EXPLAIN' + explain_options = frozenset( +diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py +index 8082c7af7585..70dd793a6c7c 100644 +--- a/tests/annotations/tests.py ++++ b/tests/annotations/tests.py +@@ -2,10 +2,12 @@ import datetime + from decimal import Decimal + + from django.core.exceptions import FieldDoesNotExist, FieldError ++from django.db import connection + from django.db.models import ( + BooleanField, Case, CharField, Count, DateTimeField, DecimalField, Exists, + ExpressionWrapper, F, FloatField, Func, IntegerField, Max, + NullBooleanField, OuterRef, Q, Subquery, Sum, Value, When, ++ FilteredRelation, + ) + from django.db.models.expressions import RawSQL + from django.db.models.functions import ( +@@ -1039,3 +1041,14 @@ class AliasTests(TestCase): + ) + with self.assertRaisesMessage(ValueError, msg): + Book.objects.alias(**{crafted_alias: Value(1)}) ++ ++ 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-3.2.19/debian/patches/0024-CVE-2025-26699.patch python-django-3.2.25/debian/patches/0024-CVE-2025-26699.patch --- python-django-3.2.19/debian/patches/0024-CVE-2025-26699.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0024-CVE-2025-26699.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,76 @@ +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Tue, 25 Feb 2025 09:40:54 +0100 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-26699 -- Mitigated potential DoS in + wordwrap template filter. + +Thanks sw0rd1ight for the report. + +Backport of 55d89e25f4115c5674cdd9b9bcba2bb2bb6d820b from main. +--- + django/utils/text.py | 27 ++++++++-------------- + tests/template_tests/filter_tests/test_wordwrap.py | 11 +++++++++ + 2 files changed, 21 insertions(+), 17 deletions(-) + +diff --git a/django/utils/text.py b/django/utils/text.py +index 88da9a2c2c6b..cabd76f33f82 100644 +--- a/django/utils/text.py ++++ b/django/utils/text.py +@@ -1,5 +1,6 @@ + import html.entities + import re ++import textwrap + import unicodedata + import warnings + from gzip import GzipFile +@@ -91,23 +92,15 @@ def wrap(text, width): + Don't wrap long words, thus the output text may have lines longer than + ``width``. + """ +- def _generator(): +- for line in text.splitlines(True): # True keeps trailing linebreaks +- max_width = min((line.endswith('\n') and width + 1 or width), width) +- while len(line) > max_width: +- space = line[:max_width + 1].rfind(' ') + 1 +- if space == 0: +- space = line.find(' ') + 1 +- if space == 0: +- yield line +- line = '' +- break +- yield '%s\n' % line[:space - 1] +- line = line[space:] +- max_width = min((line.endswith('\n') and width + 1 or width), width) +- if line: +- yield line +- return ''.join(_generator()) ++ wrapper = textwrap.TextWrapper( ++ width=width, ++ break_long_words=False, ++ break_on_hyphens=False, ++ ) ++ result = [] ++ for line in text.splitlines(True): ++ result.extend(wrapper.wrap(line)) ++ return "\n".join(result) + + + class Truncator(SimpleLazyObject): +diff --git a/tests/template_tests/filter_tests/test_wordwrap.py b/tests/template_tests/filter_tests/test_wordwrap.py +index 02f860582ba7..f61842cb19aa 100644 +--- a/tests/template_tests/filter_tests/test_wordwrap.py ++++ b/tests/template_tests/filter_tests/test_wordwrap.py +@@ -51,3 +51,14 @@ class FunctionTests(SimpleTestCase): + ), 14), + 'this is a long\nparagraph of\ntext that\nreally needs\nto be wrapped\nI\'m afraid', + ) ++ ++ def test_wrap_long_text(self): ++ long_text = ( ++ "this is a long paragraph of text that really needs" ++ " to be wrapped I'm afraid " * 20_000 ++ ) ++ self.assertIn( ++ "this is a\nlong\nparagraph\nof text\nthat\nreally\nneeds to\nbe wrapped\n" ++ "I'm afraid", ++ wordwrap(long_text, 10), ++ ) diff -Nru python-django-3.2.19/debian/patches/0025-CVE-2025-32873.patch python-django-3.2.25/debian/patches/0025-CVE-2025-32873.patch --- python-django-3.2.19/debian/patches/0025-CVE-2025-32873.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0025-CVE-2025-32873.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,80 @@ +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Tue, 8 Apr 2025 16:30:17 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-32873 -- Mitigated potential DoS in + strip_tags(). MIME-Version: 1.0 Content-Type: text/plain; + charset=UTF-8 Content-Transfer-Encoding: 8bit +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Thanks to Elias Myllymäki for the report, and Shai Berger and Jake +Howard for the reviews. + +Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> + +Backport of 9f3419b519799d69f2aba70b9d25abe2e70d03e0 from main. +--- + django/utils/html.py | 6 ++++++ + tests/utils_tests/test_html.py | 15 ++++++++++++++- + 2 files changed, 20 insertions(+), 1 deletion(-) + +diff --git a/django/utils/html.py b/django/utils/html.py +index 5887bf1b4d7d..1a3033e697b3 100644 +--- a/django/utils/html.py ++++ b/django/utils/html.py +@@ -33,6 +33,9 @@ simple_url_2_re = _lazy_re_compile( + MAX_URL_LENGTH = 2048 + MAX_STRIP_TAGS_DEPTH = 50 + ++# HTML tag that opens but has no closing ">" after 1k+ chars. ++long_open_tag_without_closing_re = _lazy_re_compile(r"<[a-zA-Z][^>]{1000,}") ++ + + @keep_lazy(str, SafeString) + def escape(text): +@@ -184,6 +187,9 @@ def _strip_once(value): + def strip_tags(value): + """Return the given HTML with all tags stripped.""" + value = str(value) ++ for long_open_tag in long_open_tag_without_closing_re.finditer(value): ++ if long_open_tag.group().count("<") >= MAX_STRIP_TAGS_DEPTH: ++ raise SuspiciousOperation + # Note: in typical case this loop executes _strip_once twice (the second + # execution does not remove any more tags). + strip_tags_depth = 0 +diff --git a/tests/utils_tests/test_html.py b/tests/utils_tests/test_html.py +index 231f5d8f96bc..5c2de01fe234 100644 +--- a/tests/utils_tests/test_html.py ++++ b/tests/utils_tests/test_html.py +@@ -94,17 +94,30 @@ class TestUtilsHtml(SimpleTestCase): + ('>br>br>br>X', 'XX'), + ("<" * 50 + "a>" * 50, ""), ++ (">" + "" + "" * 51, "" + with self.assertRaises(SuspiciousOperation): + strip_tags(value) + ++ def test_strip_tags_suspicious_operation_large_open_tags(self): ++ items = [ ++ ">" + " +Date: Tue, 20 May 2025 15:29:52 -0300 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-48432 -- Escaped formatting arguments + in `log_response()`. + +Suitably crafted requests containing a CRLF sequence in the request +path may have allowed log injection, potentially corrupting log files, +obscuring other attacks, misleading log post-processing tools, or +forging log entries. + +To mitigate this, all positional formatting arguments passed to the +logger are now escaped using "unicode_escape" encoding. + +Thanks to Seokchan Yoon (https://ch4n3.kr/) for the report. + +Co-authored-by: Carlton Gibson +Co-authored-by: Jake Howard + +Backport of a07ebec5591e233d8bbb38b7d63f35c5479eef0e from main. +--- + django/utils/log.py | 7 ++++++- + tests/logging_tests/tests.py | 14 ++++++++++++++ + 2 files changed, 20 insertions(+), 1 deletion(-) + +diff --git a/django/utils/log.py b/django/utils/log.py +index 3d3e8701c7b9..6d5c812d3284 100644 +--- a/django/utils/log.py ++++ b/django/utils/log.py +@@ -221,8 +221,13 @@ def log_response(message, *args, response=None, request=None, logger=request_log + else: + level = 'info' + ++ escaped_args = tuple( ++ a.encode("unicode_escape").decode("ascii") if isinstance(a, str) else a ++ for a in args ++ ) ++ + getattr(logger, level)( +- message, *args, ++ message, *escaped_args, + extra={ + 'status_code': response.status_code, + 'request': request, +diff --git a/tests/logging_tests/tests.py b/tests/logging_tests/tests.py +index e5659057952f..5da64c76d88f 100644 +--- a/tests/logging_tests/tests.py ++++ b/tests/logging_tests/tests.py +@@ -122,6 +122,14 @@ class HandlerLoggingTests(SetupDefaultLoggingMixin, LoggingAssertionMixin, Loggi + self.client.get('/redirect/') + self.assertEqual(self.logger_output.getvalue(), '') + ++ def test_control_chars_escaped(self): ++ self.assertLogsRequest( ++ url="/%1B[1;31mNOW IN RED!!!1B[0m/", ++ level="WARNING", ++ status_code=404, ++ msg=r"Not Found: /\x1b[1;31mNOW IN RED!!!1B[0m/", ++ ) ++ + def test_page_not_found_warning(self): + self.assertLogsRequest( + url='/does_not_exist/', +@@ -130,6 +138,12 @@ class HandlerLoggingTests(SetupDefaultLoggingMixin, LoggingAssertionMixin, Loggi + msg='Not Found: /does_not_exist/', + ) + ++ async def test_async_control_chars_escaped(self): ++ logger = "django.request" ++ level = "WARNING" ++ with self.assertLogs(logger, level) as cm: ++ await self.async_client.get(r"/%1B[1;31mNOW IN RED!!!1B[0m/") ++ + def test_page_not_found_raised(self): + self.assertLogsRequest( + url='/does_not_exist_raised/', diff -Nru python-django-3.2.19/debian/patches/0027-CVE-2025-48432-2.patch python-django-3.2.25/debian/patches/0027-CVE-2025-48432-2.patch --- python-django-3.2.19/debian/patches/0027-CVE-2025-48432-2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0027-CVE-2025-48432-2.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,127 @@ +From: Jake Howard +Date: Wed, 4 Jun 2025 16:08:46 +0100 +Subject: [PATCH] [4.2.x] Refs CVE-2025-48432 -- Prevented log injection in + remaining response logging. + +Migrated remaining response-related logging to use the `log_response()` +helper to avoid potential log injection, to ensure untrusted values like +request paths are safely escaped. + +Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> + +Backport of 957951755259b412d5113333b32bf85871d29814 from main. +--- + django/views/generic/base.py | 17 +++++++++-------- + tests/generic_views/test_base.py | 28 ++++++++++++++++++++++++++-- + 2 files changed, 35 insertions(+), 10 deletions(-) + +diff --git a/django/views/generic/base.py b/django/views/generic/base.py +index ab800ebce84b..a176d686f856 100644 +--- a/django/views/generic/base.py ++++ b/django/views/generic/base.py +@@ -9,6 +9,7 @@ from django.http import ( + from django.template.response import TemplateResponse + from django.urls import reverse + from django.utils.decorators import classonlymethod ++from django.utils.log import log_response + + logger = logging.getLogger('django.request') + +@@ -98,11 +99,13 @@ class View: + return handler(request, *args, **kwargs) + + def http_method_not_allowed(self, request, *args, **kwargs): +- logger.warning( ++ response = HttpResponseNotAllowed(self._allowed_methods()) ++ log_response( + 'Method Not Allowed (%s): %s', request.method, request.path, +- extra={'status_code': 405, 'request': request} ++ response=response, ++ request=request, + ) +- return HttpResponseNotAllowed(self._allowed_methods()) ++ return response + + def options(self, request, *args, **kwargs): + """Handle responding to requests for the OPTIONS HTTP verb.""" +@@ -193,11 +196,9 @@ class RedirectView(View): + else: + return HttpResponseRedirect(url) + else: +- logger.warning( +- 'Gone: %s', request.path, +- extra={'status_code': 410, 'request': request} +- ) +- return HttpResponseGone() ++ response = HttpResponseGone() ++ log_response("Gone: %s", request.path, response=response, request=request) ++ return response + + def head(self, request, *args, **kwargs): + return self.get(request, *args, **kwargs) +diff --git a/tests/generic_views/test_base.py b/tests/generic_views/test_base.py +index f4b8a487ff4f..19e5ce654cf8 100644 +--- a/tests/generic_views/test_base.py ++++ b/tests/generic_views/test_base.py +@@ -1,5 +1,8 @@ ++import logging + import time + ++from logging_tests.tests import LoggingAssertionMixin ++ + from django.core.exceptions import ImproperlyConfigured + from django.http import HttpResponse + from django.test import RequestFactory, SimpleTestCase, override_settings +@@ -64,7 +67,7 @@ class InstanceView(View): + return self + + +-class ViewTest(SimpleTestCase): ++class ViewTest(LoggingAssertionMixin, SimpleTestCase): + rf = RequestFactory() + + def _assert_simple(self, response): +@@ -286,6 +289,17 @@ class ViewTest(SimpleTestCase): + response = view.dispatch(self.rf.head('/')) + self.assertEqual(response.status_code, 405) + ++ def test_method_not_allowed_response_logged(self): ++ for path, escaped in [ ++ ("/foo/", "/foo/"), ++ (r"/%1B[1;31mNOW IN RED!!!1B[0m/", r"/\x1b[1;31mNOW IN RED!!!1B[0m/"), ++ ]: ++ with self.subTest(path=path): ++ request = self.rf.get(path, REQUEST_METHOD="BOGUS") ++ with self.assertLogs("django.request", "WARNING") as handler: ++ response = SimpleView.as_view()(request) ++ self.assertEqual(response.status_code, 405) ++ + + @override_settings(ROOT_URLCONF='generic_views.urls') + class TemplateViewTest(SimpleTestCase): +@@ -407,7 +421,7 @@ class TemplateViewTest(SimpleTestCase): + + + @override_settings(ROOT_URLCONF='generic_views.urls') +-class RedirectViewTest(SimpleTestCase): ++class RedirectViewTest(LoggingAssertionMixin, SimpleTestCase): + + rf = RequestFactory() + +@@ -519,6 +533,16 @@ class RedirectViewTest(SimpleTestCase): + response = view.dispatch(self.rf.head('/foo/')) + self.assertEqual(response.status_code, 410) + ++ def test_gone_response_logged(self): ++ for path, escaped in [ ++ ("/foo/", "/foo/"), ++ (r"/%1B[1;31mNOW IN RED!!!1B[0m/", r"/\x1b[1;31mNOW IN RED!!!1B[0m/"), ++ ]: ++ with self.subTest(path=path): ++ request = self.rf.get(path) ++ with self.assertLogs("django.request", "WARNING") as handler: ++ RedirectView().dispatch(request) ++ + + class GetContextDataTest(SimpleTestCase): + diff -Nru python-django-3.2.19/debian/patches/0028-CVE-2025-57833.patch python-django-3.2.25/debian/patches/0028-CVE-2025-57833.patch --- python-django-3.2.19/debian/patches/0028-CVE-2025-57833.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0028-CVE-2025-57833.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,66 @@ +From: Jake Howard +Date: Wed, 13 Aug 2025 14:13:42 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-57833 -- Protected FilteredRelation + against SQL injection in column aliases. + +Thanks Eyal Gabay (EyalSec) for the report. + +Backport of 51711717098d3f469f795dfa6bc3758b24f69ef7 from main. +--- + django/db/models/sql/query.py | 1 + + tests/annotations/tests.py | 23 +++++++++++++++++++++++ + 2 files changed, 24 insertions(+) + +diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py +index 56afcda64f8b..0b3c313f27ba 100644 +--- a/django/db/models/sql/query.py ++++ b/django/db/models/sql/query.py +@@ -1473,6 +1473,7 @@ class Query(BaseExpression): + return target_clause + + def add_filtered_relation(self, filtered_relation, alias): ++ self.check_alias(alias) + filtered_relation.alias = alias + lookups = dict(get_children_from_q(filtered_relation.condition)) + relation_lookup_parts, relation_field_parts, _ = self.solve_lookup_type(filtered_relation.relation_name) +diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py +index 70dd793a6c7c..c554cdf90ac9 100644 +--- a/tests/annotations/tests.py ++++ b/tests/annotations/tests.py +@@ -777,6 +777,24 @@ class NonAggregateAnnotationTestCase(TestCase): + 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, 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(self): ++ crafted_alias = """injected_name" from "annotations_book"; --""" ++ msg = ( ++ "Column aliases cannot contain whitespace characters, 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', +@@ -802,6 +820,11 @@ class NonAggregateAnnotationTestCase(TestCase): + 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 diff -Nru python-django-3.2.19/debian/patches/0029-CVE-2025-59681.patch python-django-3.2.25/debian/patches/0029-CVE-2025-59681.patch --- python-django-3.2.19/debian/patches/0029-CVE-2025-59681.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0029-CVE-2025-59681.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,166 @@ +From: Mariusz Felisiak +Date: Wed, 10 Sep 2025 09:53:52 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-59681 -- Protected + QuerySet.annotate(), alias(), aggregate(), + and extra() against SQL injection in column aliases on MySQL/MariaDB. + +Thanks sw0rd1ight for the report. + +Follow up to 93cae5cb2f9a4ef1514cf1a41f714fef08005200. + +Backport of 41b43c74bda19753c757036673ea9db74acf494a from main. +--- + django/db/models/sql/query.py | 8 ++++---- + tests/aggregation/tests.py | 4 ++-- + tests/annotations/tests.py | 21 +++++++++++---------- + tests/expressions/test_queryset_values.py | 8 ++++---- + tests/queries/tests.py | 4 ++-- + 5 files changed, 23 insertions(+), 22 deletions(-) + +diff --git a/django/db/models/sql/query.py b/django/db/models/sql/query.py +index 0b3c313f27ba..85dab7b47631 100644 +--- a/django/db/models/sql/query.py ++++ b/django/db/models/sql/query.py +@@ -46,9 +46,9 @@ from django.utils.tree import Node + + __all__ = ['Query', 'RawQuery'] + +-# Quotation marks ('"`[]), whitespace characters, semicolons, or inline ++# Quotation marks ('"`[]), whitespace characters, semicolons, hashes, or inline + # SQL comments are forbidden in column aliases. +-FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|--|/\*|\*/") ++FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"\]\[;\s]|#|--|/\*|\*/") + + # Inspired from + # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS +@@ -1051,8 +1051,8 @@ class Query(BaseExpression): + def check_alias(self, alias): + if FORBIDDEN_ALIAS_PATTERN.search(alias): + raise ValueError( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, " ++ "quotation marks, semicolons, or SQL comments." + ) + + def add_annotation(self, annotation, alias, is_summary=False, select=True): +diff --git a/tests/aggregation/tests.py b/tests/aggregation/tests.py +index 48502c4a4a25..827247721068 100644 +--- a/tests/aggregation/tests.py ++++ b/tests/aggregation/tests.py +@@ -1371,8 +1371,8 @@ class AggregateTestCase(TestCase): + def test_alias_sql_injection(self): + crafted_alias = """injected_name" from "aggregation_author"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): + Author.objects.aggregate(**{crafted_alias: Avg("age")}) +diff --git a/tests/annotations/tests.py b/tests/annotations/tests.py +index c554cdf90ac9..1d42e046def1 100644 +--- a/tests/annotations/tests.py ++++ b/tests/annotations/tests.py +@@ -771,8 +771,8 @@ class NonAggregateAnnotationTestCase(TestCase): + def test_alias_sql_injection(self): + crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): + Book.objects.annotate(**{crafted_alias: Value(1)}) +@@ -780,7 +780,7 @@ class NonAggregateAnnotationTestCase(TestCase): + def test_alias_filtered_relation_sql_injection(self): + crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " ++ "Column aliases cannot contain whitespace characters, hashes, quotation marks, " + "semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): +@@ -789,8 +789,8 @@ class NonAggregateAnnotationTestCase(TestCase): + def test_alias_filtered_relation_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: FilteredRelation("author")}) +@@ -807,13 +807,14 @@ class NonAggregateAnnotationTestCase(TestCase): + "ali/*as", + "alias*/", + "alias;", +- # [] are used by MSSQL. ++ # [] and # are used by MSSQL. + "alias[", + "alias]", ++ "ali#as", + ] + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + for crafted_alias in tests: + with self.subTest(crafted_alias): +@@ -1059,8 +1060,8 @@ class AliasTests(TestCase): + def test_alias_sql_injection(self): + crafted_alias = """injected_name" from "annotations_book"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): + Book.objects.alias(**{crafted_alias: Value(1)}) +diff --git a/tests/expressions/test_queryset_values.py b/tests/expressions/test_queryset_values.py +index 2b524d8d99fe..97bfa107e07b 100644 +--- a/tests/expressions/test_queryset_values.py ++++ b/tests/expressions/test_queryset_values.py +@@ -29,8 +29,8 @@ class ValuesExpressionsTests(TestCase): + def test_values_expression_alias_sql_injection(self): + crafted_alias = """injected_name" from "expressions_company"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): + Company.objects.values(**{crafted_alias: F("ceo__salary")}) +@@ -39,8 +39,8 @@ class ValuesExpressionsTests(TestCase): + def test_values_expression_alias_sql_injection_json_field(self): + crafted_alias = """injected_name" from "expressions_company"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): + JSONFieldModel.objects.values(f"data__{crafted_alias}") +diff --git a/tests/queries/tests.py b/tests/queries/tests.py +index 4e6ff67e4906..e6ab6dffe814 100644 +--- a/tests/queries/tests.py ++++ b/tests/queries/tests.py +@@ -1680,8 +1680,8 @@ class Queries5Tests(TestCase): + def test_extra_select_alias_sql_injection(self): + crafted_alias = """injected_name" from "queries_note"; --""" + msg = ( +- "Column aliases cannot contain whitespace characters, quotation marks, " +- "semicolons, or SQL comments." ++ "Column aliases cannot contain whitespace characters, hashes, quotation " ++ "marks, semicolons, or SQL comments." + ) + with self.assertRaisesMessage(ValueError, msg): + Note.objects.extra(select={crafted_alias: "1"}) diff -Nru python-django-3.2.19/debian/patches/0030-CVE-2025-59682.patch python-django-3.2.25/debian/patches/0030-CVE-2025-59682.patch --- python-django-3.2.19/debian/patches/0030-CVE-2025-59682.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0030-CVE-2025-59682.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,66 @@ +From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> +Date: Tue, 16 Sep 2025 17:13:36 +0200 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-59682 -- Fixed potential partial + directory-traversal via archive.extract(). + +Thanks stackered for the report. + +Follow up to 05413afa8c18cdb978fcdf470e09f7a12b234a23. + +Backport of 924a0c092e65fa2d0953fd1855d2dc8786d94de2 from main. +--- + django/utils/archive.py | 6 +++++- + tests/utils_tests/test_archive.py | 19 +++++++++++++++++++ + 2 files changed, 24 insertions(+), 1 deletion(-) + +diff --git a/django/utils/archive.py b/django/utils/archive.py +index d5a0cf044657..0c9e4feb0d74 100644 +--- a/django/utils/archive.py ++++ b/django/utils/archive.py +@@ -138,7 +138,11 @@ class BaseArchive: + def target_filename(self, to_path, name): + target_path = os.path.abspath(to_path) + filename = os.path.abspath(os.path.join(target_path, name)) +- if not filename.startswith(target_path): ++ try: ++ if os.path.commonpath([target_path, filename]) != target_path: ++ raise SuspiciousOperation("Archive contains invalid path: '%s'" % name) ++ except ValueError: ++ # Different drives on Windows raises ValueError. + raise SuspiciousOperation("Archive contains invalid path: '%s'" % name) + return filename + +diff --git a/tests/utils_tests/test_archive.py b/tests/utils_tests/test_archive.py +index 4eb47d2fad76..74981187f36d 100644 +--- a/tests/utils_tests/test_archive.py ++++ b/tests/utils_tests/test_archive.py +@@ -3,6 +3,7 @@ import stat + import sys + import tempfile + import unittest ++import zipfile + + from django.core.exceptions import SuspiciousOperation + from django.test import SimpleTestCase +@@ -87,3 +88,21 @@ class TestArchiveInvalid(SimpleTestCase): + with self.subTest(entry), tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaisesMessage(SuspiciousOperation, msg % invalid_path): + archive.extract(os.path.join(archives_dir, entry), tmpdir) ++ ++ def test_extract_function_traversal_startswith(self): ++ with tempfile.TemporaryDirectory() as tmpdir: ++ base = os.path.abspath(tmpdir) ++ tarfile_handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) ++ tar_path = tarfile_handle.name ++ tarfile_handle.close() ++ self.addCleanup(os.remove, tar_path) ++ ++ malicious_member = os.path.join(base + "abc", "evil.txt") ++ with zipfile.ZipFile(tar_path, "w") as zf: ++ zf.writestr(malicious_member, "evil\n") ++ zf.writestr("test.txt", "data\n") ++ ++ with self.assertRaisesMessage( ++ SuspiciousOperation, "Archive contains invalid path" ++ ): ++ archive.extract(tar_path, base) diff -Nru python-django-3.2.19/debian/patches/0031-CVE-2025-64459.patch python-django-3.2.25/debian/patches/0031-CVE-2025-64459.patch --- python-django-3.2.19/debian/patches/0031-CVE-2025-64459.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0031-CVE-2025-64459.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,44 @@ +From: Jacob Walls +Date: Wed, 24 Sep 2025 15:54:51 -0400 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-64459 -- Prevented SQL injections in + Q/QuerySet via the _connector kwarg. + +Thanks cyberstan for the report, Sarah Boyce, Adam Johnson, Simon +Charette, and Jake Howard for the reviews. + +Backport of c880530ddd4fabd5939bab0e148bebe36699432a from main. +--- + django/db/models/query_utils.py | 4 ++++ + tests/queries/test_q.py | 5 +++++ + 2 files changed, 9 insertions(+) + +diff --git a/django/db/models/query_utils.py b/django/db/models/query_utils.py +index e8ad7cd63553..ae364bca24ad 100644 +--- a/django/db/models/query_utils.py ++++ b/django/db/models/query_utils.py +@@ -64,8 +64,12 @@ class Q(tree.Node): + OR = 'OR' + default = AND + conditional = True ++ connectors = (None, AND, OR) + + 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, negated=_negated) + + def _combine(self, other, conn): +diff --git a/tests/queries/test_q.py b/tests/queries/test_q.py +index d75751be6382..885699012696 100644 +--- a/tests/queries/test_q.py ++++ b/tests/queries/test_q.py +@@ -128,3 +128,8 @@ class QTests(SimpleTestCase): + q = q1 & q2 + path, args, kwargs = q.deconstruct() + self.assertEqual(Q(*args, **kwargs), q) ++ ++ def test_connector_validation(self): ++ msg = f"_connector must be one of {Q.AND!r}, {Q.OR!r}, or None." ++ with self.assertRaisesMessage(ValueError, msg): ++ Q(_connector="evil") diff -Nru python-django-3.2.19/debian/patches/0032-CVE-2025-64460.patch python-django-3.2.25/debian/patches/0032-CVE-2025-64460.patch --- python-django-3.2.19/debian/patches/0032-CVE-2025-64460.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/debian/patches/0032-CVE-2025-64460.patch 2026-01-27 19:16:59.000000000 +0000 @@ -0,0 +1,102 @@ +From: Shai Berger +Date: Sat, 11 Oct 2025 21:42:56 +0300 +Subject: [PATCH] [4.2.x] Fixed CVE-2025-64460 -- Corrected quadratic inner + text accumulation in XML serializer. + +Previously, `getInnerText()` recursively used `list.extend()` on strings, +which added each character from child nodes as a separate list element. +On deeply nested XML content, this caused the overall deserialization +work to grow quadratically with input size, potentially allowing +disproportionate CPU consumption for crafted XML. + +The fix separates collection of inner texts from joining them, so that +each subtree is joined only once, reducing the complexity to linear in +the size of the input. These changes also include a mitigation for a +xml.dom.minidom performance issue. + +Thanks Seokchan Yoon (https://ch4n3.kr/) for report. + +Co-authored-by: Jacob Walls +Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> + +Backport of 50efb718b31333051bc2dcb06911b8fa1358c98c from main. +--- + django/core/serializers/xml_serializer.py | 38 ++++++++++++++++++++++++++----- + 1 file changed, 32 insertions(+), 6 deletions(-) + +diff --git a/django/core/serializers/xml_serializer.py b/django/core/serializers/xml_serializer.py +index 88bfa590321e..8d266cae769b 100644 +--- a/django/core/serializers/xml_serializer.py ++++ b/django/core/serializers/xml_serializer.py +@@ -2,7 +2,8 @@ + XML serializer. + """ + import json +-from xml.dom import pulldom ++from contextlib import contextmanager ++from xml.dom import minidom, pulldom + from xml.sax import handler + from xml.sax.expatreader import ExpatParser as _ExpatParser + +@@ -16,6 +17,25 @@ from django.utils.xmlutils import ( + ) + + ++@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.""" + +@@ -173,7 +193,8 @@ class Deserializer(base.Deserializer): + def __next__(self): + for event, node in self.event_stream: + if event == "START_ELEMENT" and node.nodeName == "object": +- self.event_stream.expandNode(node) ++ with fast_cache_clearing(): ++ self.event_stream.expandNode(node) + return self._handle_object(node) + raise StopIteration + +@@ -334,16 +355,21 @@ class Deserializer(base.Deserializer): + + def getInnerText(node): + """Get all the inner text of a DOM node (recursively).""" ++ inner_text_list = getInnerTextList(node) ++ return "".join(inner_text_list) ++ ++def getInnerTextList(node): ++ """Return a list of the inner texts of a DOM node (recursively).""" + # inspired by https://mail.python.org/pipermail/xml-sig/2005-March/011022.html +- inner_text = [] ++ result = [] + for child in node.childNodes: + if child.nodeType == child.TEXT_NODE or child.nodeType == child.CDATA_SECTION_NODE: +- inner_text.append(child.data) ++ result.append(child.data) + elif child.nodeType == child.ELEMENT_NODE: +- inner_text.extend(getInnerText(child)) ++ result.extend(getInnerTextList(child)) + else: + pass +- return "".join(inner_text) ++ return result + + + # Below code based on Christian Heimes' defusedxml diff -Nru python-django-3.2.19/debian/patches/series python-django-3.2.25/debian/patches/series --- python-django-3.2.19/debian/patches/series 2024-08-21 11:08:24.000000000 +0000 +++ python-django-3.2.25/debian/patches/series 2026-01-27 19:16:59.000000000 +0000 @@ -10,11 +10,22 @@ 0011-Moved-RequestSite-import-to-the-toplevel.patch 0012-Add-Python-3.11-support-for-tests.patch 0013-fix-url-validator.patch -0014-CVE-2023-36053.patch -0015-CVE-2024-39329.patch -0016-CVE-2024-39330.patch -0017-CVE-2024-39614-1.patch -0018-CVE-2024-39614-2.patch -0019-CVE-2024-41989.patch -0020-CVE-2024-41991.patch -0021-CVE-2024-42005.patch +0014-CVE-2024-39329.patch +0015-CVE-2024-39330.patch +0016-CVE-2024-39614.patch +0017-CVE-2024-41989.patch +0018-CVE-2024-41991.patch +0019-CVE-2024-42005.patch +0020-CVE-2024-45231.patch +0021-CVE-2024-53907.patch +0022-CVE-2024-56374.patch +0023-CVE-2025-13372.patch +0024-CVE-2025-26699.patch +0025-CVE-2025-32873.patch +0026-CVE-2025-48432.patch +0027-CVE-2025-48432-2.patch +0028-CVE-2025-57833.patch +0029-CVE-2025-59681.patch +0030-CVE-2025-59682.patch +0031-CVE-2025-64459.patch +0032-CVE-2025-64460.patch diff -Nru python-django-3.2.19/django/__init__.py python-django-3.2.25/django/__init__.py --- python-django-3.2.19/django/__init__.py 2023-05-03 11:59:16.000000000 +0000 +++ python-django-3.2.25/django/__init__.py 2024-03-04 07:48:09.000000000 +0000 @@ -1,6 +1,6 @@ from django.utils.version import get_version -VERSION = (3, 2, 19, 'final', 0) +VERSION = (3, 2, 25, 'final', 0) __version__ = get_version(VERSION) diff -Nru python-django-3.2.19/django/contrib/auth/forms.py python-django-3.2.25/django/contrib/auth/forms.py --- python-django-3.2.19/django/contrib/auth/forms.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/django/contrib/auth/forms.py 2024-03-04 07:47:52.000000000 +0000 @@ -62,7 +62,15 @@ class UsernameField(forms.CharField): def to_python(self, value): - return unicodedata.normalize('NFKC', super().to_python(value)) + value = super().to_python(value) + if self.max_length is not None and len(value) > self.max_length: + # Normalization can increase the string length (e.g. + # "ff" -> "ff", "½" -> "1⁄2") but cannot reduce it, so there is no + # point in normalizing invalid data. Moreover, Unicode + # normalization is very slow on Windows and can be a DoS attack + # vector. + return value + return unicodedata.normalize("NFKC", value) def widget_attrs(self, widget): return { diff -Nru python-django-3.2.19/django/contrib/humanize/templatetags/humanize.py python-django-3.2.25/django/contrib/humanize/templatetags/humanize.py --- python-django-3.2.19/django/contrib/humanize/templatetags/humanize.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/django/contrib/humanize/templatetags/humanize.py 2024-03-04 07:47:52.000000000 +0000 @@ -70,12 +70,15 @@ return intcomma(value, False) else: return number_format(value, use_l10n=True, force_grouping=True) - orig = str(value) - new = re.sub(r"^(-?\d+)(\d{3})", r'\g<1>,\g<2>', orig) - if orig == new: - return new - else: - return intcomma(new, use_l10n) + result = str(value) + match = re.match(r"-?\d+", result) + if match: + prefix = match[0] + prefix_with_commas = re.sub(r"\d{3}", r"\g<0>,", prefix[::-1])[::-1] + # Remove a leading comma, if needed. + prefix_with_commas = re.sub(r"^(-?),", r"\1", prefix_with_commas) + result = prefix_with_commas + result[len(prefix) :] + return result # A tuple of standard large number to their converters diff -Nru python-django-3.2.19/django/core/validators.py python-django-3.2.25/django/core/validators.py --- python-django-3.2.19/django/core/validators.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/django/core/validators.py 2024-03-04 07:47:52.000000000 +0000 @@ -93,6 +93,7 @@ message = _('Enter a valid URL.') schemes = ['http', 'https', 'ftp', 'ftps'] unsafe_chars = frozenset('\t\r\n') + max_length = 2048 def __init__(self, schemes=None, **kwargs): super().__init__(**kwargs) @@ -100,7 +101,7 @@ self.schemes = schemes def __call__(self, value): - if not isinstance(value, str): + if not isinstance(value, str) or len(value) > self.max_length: raise ValidationError(self.message, code=self.code, params={'value': value}) if self.unsafe_chars.intersection(value): raise ValidationError(self.message, code=self.code, params={'value': value}) @@ -210,7 +211,9 @@ self.domain_allowlist = allowlist def __call__(self, value): - if not value or '@' not in value: + # The maximum length of an email is 320 characters per RFC 3696 + # section 3. + if not value or '@' not in value or len(value) > 320: raise ValidationError(self.message, code=self.code, params={'value': value}) user_part, domain_part = value.rsplit('@', 1) diff -Nru python-django-3.2.19/django/forms/fields.py python-django-3.2.25/django/forms/fields.py --- python-django-3.2.19/django/forms/fields.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/django/forms/fields.py 2024-03-04 07:47:52.000000000 +0000 @@ -540,6 +540,9 @@ default_validators = [validators.validate_email] def __init__(self, **kwargs): + # The default maximum length of an email is 320 characters per RFC 3696 + # section 3. + kwargs.setdefault("max_length", 320) super().__init__(strip=True, **kwargs) diff -Nru python-django-3.2.19/django/utils/encoding.py python-django-3.2.25/django/utils/encoding.py --- python-django-3.2.19/django/utils/encoding.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/django/utils/encoding.py 2024-03-04 07:47:52.000000000 +0000 @@ -229,6 +229,7 @@ repercent-encode any octet produced that is not part of a strictly legal UTF-8 octet sequence. """ + changed_parts = [] while True: try: path.decode() @@ -236,9 +237,10 @@ # CVE-2019-14235: A recursion shouldn't be used since the exception # handling uses massive amounts of memory repercent = quote(path[e.start:e.end], safe=b"/#%[]=:;$&()+,!?*@'~") - path = path[:e.start] + repercent.encode() + path[e.end:] + changed_parts.append(path[:e.start] + repercent.encode()) + path = path[e.end:] else: - return path + return b"".join(changed_parts) + path def filepath_to_uri(path): diff -Nru python-django-3.2.19/django/utils/text.py python-django-3.2.25/django/utils/text.py --- python-django-3.2.19/django/utils/text.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/django/utils/text.py 2024-03-04 07:47:52.000000000 +0000 @@ -18,8 +18,61 @@ return x and str(x)[0].upper() + str(x)[1:] -# Set up regular expressions -re_words = _lazy_re_compile(r'<[^>]+?>|([^<>\s]+)', re.S) +# ----- Begin security-related performance workaround ----- + +# We used to have, below +# +# re_words = _lazy_re_compile(r"<[^>]+?>|([^<>\s]+)", re.S) +# +# But it was shown that this regex, in the way we use it here, has some +# catastrophic edge-case performance features. Namely, when it is applied to +# text with only open brackets "<<<...". The class below provides the services +# and correct answers for the use cases, but in these edge cases does it much +# faster. +re_notag = _lazy_re_compile(r"([^<>\s]+)", re.S) +re_prt = _lazy_re_compile(r"<|([^<>\s]+)", re.S) + + +class WordsRegex: + @staticmethod + def search(text, pos): + # Look for "<" or a non-tag word. + partial = re_prt.search(text, pos) + if partial is None or partial[1] is not None: + return partial + + # "<" was found, look for a closing ">". + end = text.find(">", partial.end(0)) + if end < 0: + # ">" cannot be found, look for a word. + return re_notag.search(text, pos + 1) + else: + # "<" followed by a ">" was found -- fake a match. + end += 1 + return FakeMatch(text[partial.start(0): end], end) + + +class FakeMatch: + __slots__ = ["_text", "_end"] + + def end(self, group=0): + assert group == 0, "This specific object takes only group=0" + return self._end + + def __getitem__(self, group): + if group == 1: + return None + assert group == 0, "This specific object takes only group in {0,1}" + return self._text + + def __init__(self, text, end): + self._text, self._end = text, end + + +# ----- End security-related performance workaround ----- + +# Set up regular expressions. +re_words = WordsRegex re_chars = _lazy_re_compile(r'<[^>]+?>|(.)', re.S) re_tag = _lazy_re_compile(r'<(/)?(\S+?)(?:(\s*/)|\s.*?)?>', re.S) re_newlines = _lazy_re_compile(r'\r\n|\r') # Used in normalize_newlines @@ -60,7 +113,14 @@ class Truncator(SimpleLazyObject): """ An object used to truncate text, either by characters or words. + + When truncating HTML text (either chars or words), input will be limited to + at most `MAX_LENGTH_HTML` characters. """ + + # 5 million characters are approximately 4000 text pages or 3 web pages. + MAX_LENGTH_HTML = 5_000_000 + def __init__(self, text): super().__init__(lambda: str(text)) @@ -157,6 +217,11 @@ if words and length <= 0: return '' + size_limited = False + if len(text) > self.MAX_LENGTH_HTML: + text = text[: self.MAX_LENGTH_HTML] + size_limited = True + html4_singlets = ( 'br', 'col', 'link', 'base', 'img', 'param', 'area', 'hr', 'input' @@ -206,10 +271,14 @@ # Add it to the start of the open tags list open_tags.insert(0, tagname) + truncate_text = self.add_truncation_text("", truncate) + if current_len <= length: + if size_limited and truncate_text: + text += truncate_text return text + out = text[:end_text_pos] - truncate_text = self.add_truncation_text('', truncate) if truncate_text: out += truncate_text # Close any tags still open diff -Nru python-django-3.2.19/docs/_ext/djangodocs.py python-django-3.2.25/docs/_ext/djangodocs.py --- python-django-3.2.19/docs/_ext/djangodocs.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/docs/_ext/djangodocs.py 2024-03-04 07:47:52.000000000 +0000 @@ -130,10 +130,23 @@ def visit_desc_parameterlist(self, node): self.body.append('(') # by default sphinx puts around the "(" - self.first_param = 1 self.optional_param_level = 0 self.param_separator = node.child_text_separator - self.required_params_left = sum(isinstance(c, addnodes.desc_parameter) for c in node.children) + # Counts 'parameter groups' being either a required parameter, or a set + # of contiguous optional ones. + required_params = [ + isinstance(c, addnodes.desc_parameter) for c in node.children + ] + # How many required parameters are left. + self.required_params_left = sum(required_params) + if sphinx_version < (7, 1): + self.first_param = 1 + else: + self.is_first_param = True + self.params_left_at_level = 0 + self.param_group_index = 0 + self.list_is_required_param = required_params + self.multi_line_parameter_list = False def depart_desc_parameterlist(self, node): self.body.append(')') diff -Nru python-django-3.2.19/docs/ref/forms/fields.txt python-django-3.2.25/docs/ref/forms/fields.txt --- python-django-3.2.19/docs/ref/forms/fields.txt 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/docs/ref/forms/fields.txt 2024-03-04 07:47:52.000000000 +0000 @@ -592,7 +592,12 @@ * Error message keys: ``required``, ``invalid`` Has three optional arguments ``max_length``, ``min_length``, and - ``empty_value`` which work just as they do for :class:`CharField`. + ``empty_value`` which work just as they do for :class:`CharField`. The + ``max_length`` argument defaults to 320 (see :rfc:`3696#section-3`). + + .. versionchanged:: 3.2.20 + + The default value for ``max_length`` was changed to 320 characters. ``FileField`` ------------- diff -Nru python-django-3.2.19/docs/ref/templates/builtins.txt python-django-3.2.25/docs/ref/templates/builtins.txt --- python-django-3.2.19/docs/ref/templates/builtins.txt 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/docs/ref/templates/builtins.txt 2024-03-04 07:47:52.000000000 +0000 @@ -2348,6 +2348,16 @@ Newlines in the HTML content will be preserved. +.. admonition:: Size of input string + + Processing large, potentially malformed HTML strings can be + resource-intensive and impact service performance. ``truncatechars_html`` + limits input to the first five million characters. + +.. versionchanged:: 3.2.22 + + In older versions, strings over five million characters were processed. + .. templatefilter:: truncatewords ``truncatewords`` @@ -2386,6 +2396,16 @@ Newlines in the HTML content will be preserved. +.. admonition:: Size of input string + + Processing large, potentially malformed HTML strings can be + resource-intensive and impact service performance. ``truncatewords_html`` + limits input to the first five million characters. + +.. versionchanged:: 3.2.22 + + In older versions, strings over five million characters were processed. + .. templatefilter:: unordered_list ``unordered_list`` diff -Nru python-django-3.2.19/docs/ref/utils.txt python-django-3.2.25/docs/ref/utils.txt --- python-django-3.2.19/docs/ref/utils.txt 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/docs/ref/utils.txt 2024-03-04 07:47:52.000000000 +0000 @@ -276,7 +276,7 @@ .. deprecated:: 3.0 - Alias of :func:`force_str` for backwards compatibility, especially in code + Alias of :func:`smart_str` for backwards compatibility, especially in code that supports Python 2. .. function:: force_text(s, encoding='utf-8', strings_only=False, errors='strict') diff -Nru python-django-3.2.19/docs/ref/validators.txt python-django-3.2.25/docs/ref/validators.txt --- python-django-3.2.19/docs/ref/validators.txt 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/docs/ref/validators.txt 2024-03-04 07:47:52.000000000 +0000 @@ -130,6 +130,11 @@ :param code: If not ``None``, overrides :attr:`code`. :param allowlist: If not ``None``, overrides :attr:`allowlist`. + An :class:`EmailValidator` ensures that a value looks like an email, and + raises a :exc:`~django.core.exceptions.ValidationError` with + :attr:`message` and :attr:`code` if it doesn't. Values longer than 320 + characters are always considered invalid. + .. attribute:: message The error message used by @@ -158,13 +163,19 @@ The undocumented ``domain_whitelist`` attribute is deprecated. Use ``domain_allowlist`` instead. + .. versionchanged:: 3.2.20 + + In older versions, values longer than 320 characters could be + considered valid. + ``URLValidator`` ---------------- .. class:: URLValidator(schemes=None, regex=None, message=None, code=None) A :class:`RegexValidator` subclass that ensures a value looks like a URL, - and raises an error code of ``'invalid'`` if it doesn't. + and raises an error code of ``'invalid'`` if it doesn't. Values longer than + :attr:`max_length` characters are always considered invalid. Loopback addresses and reserved IP spaces are considered valid. Literal IPv6 addresses (:rfc:`3986#section-3.2.2`) and Unicode domains are both @@ -181,6 +192,18 @@ .. _valid URI schemes: https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml + .. attribute:: max_length + + .. versionadded:: 3.2.20 + + The maximum length of values that could be considered valid. Defaults + to 2048 characters. + + .. versionchanged:: 3.2.20 + + In older versions, values longer than 2048 characters could be + considered valid. + ``validate_email`` ------------------ diff -Nru python-django-3.2.19/docs/releases/3.2.20.txt python-django-3.2.25/docs/releases/3.2.20.txt --- python-django-3.2.19/docs/releases/3.2.20.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/docs/releases/3.2.20.txt 2023-07-03 06:32:22.000000000 +0000 @@ -0,0 +1,14 @@ +=========================== +Django 3.2.20 release notes +=========================== + +*July 3, 2023* + +Django 3.2.20 fixes a security issue with severity "moderate" in 3.2.19. + +CVE-2023-36053: Potential regular expression denial of service vulnerability in ``EmailValidator``/``URLValidator`` +=================================================================================================================== + +``EmailValidator`` and ``URLValidator`` were subject to potential regular +expression denial of service attack via a very large number of domain name +labels of emails and URLs. diff -Nru python-django-3.2.19/docs/releases/3.2.21.txt python-django-3.2.25/docs/releases/3.2.21.txt --- python-django-3.2.19/docs/releases/3.2.21.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/docs/releases/3.2.21.txt 2023-09-04 10:23:08.000000000 +0000 @@ -0,0 +1,14 @@ +=========================== +Django 3.2.21 release notes +=========================== + +*September 4, 2023* + +Django 3.2.21 fixes a security issue with severity "moderate" in 3.2.20. + +CVE-2023-41164: Potential denial of service vulnerability in ``django.utils.encoding.uri_to_iri()`` +=================================================================================================== + +``django.utils.encoding.uri_to_iri()`` was subject to potential denial of +service attack via certain inputs with a very large number of Unicode +characters. diff -Nru python-django-3.2.19/docs/releases/3.2.22.txt python-django-3.2.25/docs/releases/3.2.22.txt --- python-django-3.2.19/docs/releases/3.2.22.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/docs/releases/3.2.22.txt 2023-11-01 05:09:28.000000000 +0000 @@ -0,0 +1,25 @@ +=========================== +Django 3.2.22 release notes +=========================== + +*October 4, 2023* + +Django 3.2.22 fixes a security issue with severity "moderate" in 3.2.21. + +CVE-2023-43665: Denial-of-service possibility in ``django.utils.text.Truncator`` +================================================================================ + +Following the fix for :cve:`2019-14232`, the regular expressions used in the +implementation of ``django.utils.text.Truncator``'s ``chars()`` and ``words()`` +methods (with ``html=True``) were revised and improved. However, these regular +expressions still exhibited linear backtracking complexity, so when given a +very long, potentially malformed HTML input, the evaluation would still be +slow, leading to a potential denial of service vulnerability. + +The ``chars()`` and ``words()`` methods are used to implement the +:tfilter:`truncatechars_html` and :tfilter:`truncatewords_html` template +filters, which were thus also vulnerable. + +The input processed by ``Truncator``, when operating in HTML mode, has been +limited to the first five million characters in order to avoid potential +performance and memory issues. diff -Nru python-django-3.2.19/docs/releases/3.2.23.txt python-django-3.2.25/docs/releases/3.2.23.txt --- python-django-3.2.19/docs/releases/3.2.23.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/docs/releases/3.2.23.txt 2023-11-01 05:30:53.000000000 +0000 @@ -0,0 +1,19 @@ +=========================== +Django 3.2.23 release notes +=========================== + +*November 1, 2023* + +Django 3.2.23 fixes a security issue with severity "moderate" in 3.2.22. + +CVE-2023-46695: Potential denial of service vulnerability in ``UsernameField`` on Windows +========================================================================================= + +The :func:`NFKC normalization ` is slow on +Windows. As a consequence, ``django.contrib.auth.forms.UsernameField`` was +subject to a potential denial of service attack via certain inputs with a very +large number of Unicode characters. + +In order to avoid the vulnerability, invalid values longer than +``UsernameField.max_length`` are no longer normalized, since they cannot pass +validation anyway. diff -Nru python-django-3.2.19/docs/releases/3.2.24.txt python-django-3.2.25/docs/releases/3.2.24.txt --- python-django-3.2.19/docs/releases/3.2.24.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/docs/releases/3.2.24.txt 2024-03-04 07:21:02.000000000 +0000 @@ -0,0 +1,13 @@ +=========================== +Django 3.2.24 release notes +=========================== + +*February 6, 2024* + +Django 3.2.24 fixes a security issue with severity "moderate" in 3.2.23. + +CVE-2024-24680: Potential denial-of-service in ``intcomma`` template filter +=========================================================================== + +The ``intcomma`` template filter was subject to a potential denial-of-service +attack when used with very long strings. diff -Nru python-django-3.2.19/docs/releases/3.2.25.txt python-django-3.2.25/docs/releases/3.2.25.txt --- python-django-3.2.19/docs/releases/3.2.25.txt 1970-01-01 00:00:00.000000000 +0000 +++ python-django-3.2.25/docs/releases/3.2.25.txt 2024-03-04 07:37:35.000000000 +0000 @@ -0,0 +1,22 @@ +=========================== +Django 3.2.25 release notes +=========================== + +*March 4, 2024* + +Django 3.2.25 fixes a security issue with severity "moderate" and a regression +in 3.2.24. + +CVE-2024-27351: Potential regular expression denial-of-service in ``django.utils.text.Truncator.words()`` +========================================================================================================= + +``django.utils.text.Truncator.words()`` method (with ``html=True``) and +:tfilter:`truncatewords_html` template filter were subject to a potential +regular expression denial-of-service attack using a suitably crafted string +(follow up to :cve:`2019-14232` and :cve:`2023-43665`). + +Bugfixes +======== + +* Fixed a regression in Django 3.2.24 where ``intcomma`` template filter could + return a leading comma for string representation of floats (:ticket:`35172`). diff -Nru python-django-3.2.19/docs/releases/index.txt python-django-3.2.25/docs/releases/index.txt --- python-django-3.2.19/docs/releases/index.txt 2023-05-03 11:58:23.000000000 +0000 +++ python-django-3.2.25/docs/releases/index.txt 2024-03-04 07:47:52.000000000 +0000 @@ -25,6 +25,12 @@ .. toctree:: :maxdepth: 1 + 3.2.25 + 3.2.24 + 3.2.23 + 3.2.22 + 3.2.21 + 3.2.20 3.2.19 3.2.18 3.2.17 diff -Nru python-django-3.2.19/docs/releases/security.txt python-django-3.2.25/docs/releases/security.txt --- python-django-3.2.19/docs/releases/security.txt 2023-05-03 11:58:23.000000000 +0000 +++ python-django-3.2.25/docs/releases/security.txt 2024-03-04 07:21:02.000000000 +0000 @@ -36,6 +36,72 @@ All security issues have been handled under versions of Django's security process. These are listed below. +February 6, 2024 - :cve:`2024-24680` +------------------------------------ + +Potential denial-of-service in ``intcomma`` template filter. +`Full description +`__ + +* Django 5.0 :commit:`(patch) <16a8fe18a3b81250f4fa57e3f93f0599dc4895bc>` +* Django 4.2 :commit:`(patch) <572ea07e84b38ea8de0551f4b4eda685d91d09d2>` +* Django 3.2 :commit:`(patch) ` + +November 1, 2023 - :cve:`2023-46695` +------------------------------------ + +Potential denial of service vulnerability in ``UsernameField`` on Windows. +`Full description +`__ + +* Django 4.2 :commit:`(patch) <048a9ebb6ea468426cb4e57c71572cbbd975517f>` +* Django 4.1 :commit:`(patch) <4965bfdde2e5a5c883685019e57d123a3368a75e>` +* Django 3.2 :commit:`(patch) ` + +October 4, 2023 - :cve:`2023-43665` +----------------------------------- + +Denial-of-service possibility in ``django.utils.text.Truncator``. +`Full description +`__ + +* Django 4.2 :commit:`(patch) ` +* Django 4.1 :commit:`(patch) ` +* Django 3.2 :commit:`(patch) ` + +September 4, 2023 - :cve:`2023-41164` +------------------------------------- + +Potential denial of service vulnerability in +``django.utils.encoding.uri_to_iri()``. `Full description +`__ + +* Django 4.2 :commit:`(patch) <9c51b4dcfa0cefcb48231f4d71cafa80821f87b9>` +* Django 4.1 :commit:`(patch) ` +* Django 3.2 :commit:`(patch) <6f030b1149bd8fa4ba90452e77cb3edc095ce54e>` + +July 3, 2023 - :cve:`2023-36053` +-------------------------------- + +Potential regular expression denial of service vulnerability in +``EmailValidator``/``URLValidator``. `Full description +`__ + +* Django 4.2 :commit:`(patch) ` +* Django 4.1 :commit:`(patch) ` +* Django 3.2 :commit:`(patch) <454f2fb93437f98917283336201b4048293f7582>` + +May 3, 2023 - :cve:`2023-31047` +------------------------------- + +Potential bypass of validation when uploading multiple files using one form +field. `Full description +`__ + +* Django 4.2 :commit:`(patch) <21b1b1fc03e5f9e9f8c977ee6e35618dd3b353dd>` +* Django 4.1 :commit:`(patch) ` +* Django 3.2 :commit:`(patch) ` + February 14, 2023 - :cve:`2023-24580` ------------------------------------- diff -Nru python-django-3.2.19/tests/auth_tests/test_forms.py python-django-3.2.25/tests/auth_tests/test_forms.py --- python-django-3.2.19/tests/auth_tests/test_forms.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/tests/auth_tests/test_forms.py 2024-03-04 07:47:52.000000000 +0000 @@ -5,7 +5,7 @@ from django.contrib.auth.forms import ( AdminPasswordChangeForm, AuthenticationForm, PasswordChangeForm, PasswordResetForm, ReadOnlyPasswordHashField, ReadOnlyPasswordHashWidget, - SetPasswordForm, UserChangeForm, UserCreationForm, + SetPasswordForm, UserChangeForm, UserCreationForm, UsernameField, ) from django.contrib.auth.models import User from django.contrib.auth.signals import user_login_failed @@ -132,6 +132,12 @@ self.assertNotEqual(user.username, ohm_username) self.assertEqual(user.username, 'testΩ') # U+03A9 GREEK CAPITAL LETTER OMEGA + def test_invalid_username_no_normalize(self): + field = UsernameField(max_length=254) + # Usernames are not normalized if they are too long. + self.assertEqual(field.to_python("½" * 255), "½" * 255) + self.assertEqual(field.to_python("ff" * 254), "ff" * 254) + def test_duplicate_normalized_unicode(self): """ To prevent almost identical usernames, visually identical but differing diff -Nru python-django-3.2.19/tests/forms_tests/field_tests/test_emailfield.py python-django-3.2.25/tests/forms_tests/field_tests/test_emailfield.py --- python-django-3.2.19/tests/forms_tests/field_tests/test_emailfield.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/tests/forms_tests/field_tests/test_emailfield.py 2024-03-04 07:47:52.000000000 +0000 @@ -9,7 +9,10 @@ def test_emailfield_1(self): f = EmailField() - self.assertWidgetRendersTo(f, '') + self.assertEqual(f.max_length, 320) + self.assertWidgetRendersTo( + f, '' + ) with self.assertRaisesMessage(ValidationError, "'This field is required.'"): f.clean('') with self.assertRaisesMessage(ValidationError, "'This field is required.'"): diff -Nru python-django-3.2.19/tests/forms_tests/field_tests/test_filefield.py python-django-3.2.25/tests/forms_tests/field_tests/test_filefield.py --- python-django-3.2.19/tests/forms_tests/field_tests/test_filefield.py 2023-05-03 11:58:44.000000000 +0000 +++ python-django-3.2.25/tests/forms_tests/field_tests/test_filefield.py 2024-03-04 07:47:52.000000000 +0000 @@ -1,4 +1,5 @@ import pickle +import unittest from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile @@ -6,6 +7,13 @@ from django.forms import FileField, FileInput from django.test import SimpleTestCase +try: + from PIL import Image # NOQA +except ImportError: + HAS_PILLOW = False +else: + HAS_PILLOW = True + class FileFieldTest(SimpleTestCase): @@ -125,6 +133,7 @@ with self.assertRaisesMessage(ValidationError, msg): f.clean(files[::-1]) + @unittest.skipUnless(HAS_PILLOW, "Pillow not installed") def test_file_multiple_validation(self): f = MultipleFileField(validators=[validate_image_file_extension]) diff -Nru python-django-3.2.19/tests/forms_tests/tests/test_forms.py python-django-3.2.25/tests/forms_tests/tests/test_forms.py --- python-django-3.2.19/tests/forms_tests/tests/test_forms.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/tests/forms_tests/tests/test_forms.py 2024-03-04 07:47:52.000000000 +0000 @@ -422,11 +422,18 @@ get_spam = BooleanField() f = SignupForm(auto_id=False) - self.assertHTMLEqual(str(f['email']), '') + self.assertHTMLEqual( + str(f["email"]), + '', + ) self.assertHTMLEqual(str(f['get_spam']), '') f = SignupForm({'email': 'test@example.com', 'get_spam': True}, auto_id=False) - self.assertHTMLEqual(str(f['email']), '') + self.assertHTMLEqual( + str(f["email"]), + '", + ) self.assertHTMLEqual( str(f['get_spam']), '', @@ -2824,7 +2831,7 @@ -
  • +
    • This field is required.
  • """ ) @@ -2840,7 +2847,7 @@

    -

    +

    • This field is required.

    """ @@ -2859,7 +2866,7 @@ - +
    • This field is required.
    """ @@ -3489,7 +3496,7 @@ f = CommentForm(data, auto_id=False, error_class=DivErrorList) self.assertHTMLEqual(f.as_p(), """

    Name:

    Enter a valid email address.
    -

    Email:

    +

    Email:

    This field is required.

    Comment:

    """) diff -Nru python-django-3.2.19/tests/humanize_tests/tests.py python-django-3.2.25/tests/humanize_tests/tests.py --- python-django-3.2.19/tests/humanize_tests/tests.py 2023-05-03 11:57:57.000000000 +0000 +++ python-django-3.2.25/tests/humanize_tests/tests.py 2024-03-04 07:47:52.000000000 +0000 @@ -66,28 +66,156 @@ def test_intcomma(self): test_list = ( - 100, 1000, 10123, 10311, 1000000, 1234567.25, '100', '1000', - '10123', '10311', '1000000', '1234567.1234567', - Decimal('1234567.1234567'), None, + 100, + -100, + 1000, + -1000, + 10123, + -10123, + 10311, + -10311, + 1000000, + -1000000, + 1234567.25, + -1234567.25, + "100", + "-100", + "100.1", + "-100.1", + "100.13", + "-100.13", + "1000", + "-1000", + "10123", + "-10123", + "10311", + "-10311", + "100000.13", + "-100000.13", + "1000000", + "-1000000", + "1234567.1234567", + "-1234567.1234567", + Decimal("1234567.1234567"), + Decimal("-1234567.1234567"), + None, + "1234567", + "-1234567", + "1234567.12", + "-1234567.12", + "the quick brown fox jumped over the lazy dog", ) result_list = ( - '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.25', - '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.1234567', - '1,234,567.1234567', None, + "100", + "-100", + "1,000", + "-1,000", + "10,123", + "-10,123", + "10,311", + "-10,311", + "1,000,000", + "-1,000,000", + "1,234,567.25", + "-1,234,567.25", + "100", + "-100", + "100.1", + "-100.1", + "100.13", + "-100.13", + "1,000", + "-1,000", + "10,123", + "-10,123", + "10,311", + "-10,311", + "100,000.13", + "-100,000.13", + "1,000,000", + "-1,000,000", + "1,234,567.1234567", + "-1,234,567.1234567", + "1,234,567.1234567", + "-1,234,567.1234567", + None, + "1,234,567", + "-1,234,567", + "1,234,567.12", + "-1,234,567.12", + "the quick brown fox jumped over the lazy dog", ) with translation.override('en'): self.humanize_tester(test_list, result_list, 'intcomma') def test_l10n_intcomma(self): test_list = ( - 100, 1000, 10123, 10311, 1000000, 1234567.25, '100', '1000', - '10123', '10311', '1000000', '1234567.1234567', - Decimal('1234567.1234567'), None, + 100, + -100, + 1000, + -1000, + 10123, + -10123, + 10311, + -10311, + 1000000, + -1000000, + 1234567.25, + -1234567.25, + "100", + "-100", + "1000", + "-1000", + "10123", + "-10123", + "10311", + "-10311", + "1000000", + "-1000000", + "1234567.1234567", + "-1234567.1234567", + Decimal("1234567.1234567"), + -Decimal("1234567.1234567"), + None, + "1234567", + "-1234567", + "1234567.12", + "-1234567.12", + "the quick brown fox jumped over the lazy dog", ) result_list = ( - '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.25', - '100', '1,000', '10,123', '10,311', '1,000,000', '1,234,567.1234567', - '1,234,567.1234567', None, + "100", + "-100", + "1,000", + "-1,000", + "10,123", + "-10,123", + "10,311", + "-10,311", + "1,000,000", + "-1,000,000", + "1,234,567.25", + "-1,234,567.25", + "100", + "-100", + "1,000", + "-1,000", + "10,123", + "-10,123", + "10,311", + "-10,311", + "1,000,000", + "-1,000,000", + "1,234,567.1234567", + "-1,234,567.1234567", + "1,234,567.1234567", + "-1,234,567.1234567", + None, + "1,234,567", + "-1,234,567", + "1,234,567.12", + "-1,234,567.12", + "the quick brown fox jumped over the lazy dog", ) with self.settings(USE_L10N=True, USE_THOUSAND_SEPARATOR=False): with translation.override('en'): diff -Nru python-django-3.2.19/tests/requirements/py3.txt python-django-3.2.25/tests/requirements/py3.txt --- python-django-3.2.19/tests/requirements/py3.txt 2023-05-03 11:57:58.000000000 +0000 +++ python-django-3.2.25/tests/requirements/py3.txt 2024-03-04 07:47:52.000000000 +0000 @@ -12,7 +12,7 @@ pylibmc; sys.platform != 'win32' pymemcache >= 3.4.0 # RemovedInDjango41Warning. -python-memcached >= 1.59 +python-memcached == 1.59 pytz pywatchman; sys.platform != 'win32' PyYAML diff -Nru python-django-3.2.19/tests/utils_tests/test_encoding.py python-django-3.2.25/tests/utils_tests/test_encoding.py --- python-django-3.2.19/tests/utils_tests/test_encoding.py 2023-05-03 11:57:58.000000000 +0000 +++ python-django-3.2.25/tests/utils_tests/test_encoding.py 2024-03-04 07:47:52.000000000 +0000 @@ -1,9 +1,10 @@ import datetime +import inspect import sys import unittest from pathlib import Path from unittest import mock -from urllib.parse import quote_plus +from urllib.parse import quote, quote_plus from django.test import SimpleTestCase from django.utils.encoding import ( @@ -101,6 +102,24 @@ except RecursionError: self.fail('Unexpected RecursionError raised.') + def test_repercent_broken_unicode_small_fragments(self): + data = b"test\xfctest\xfctest\xfc" + decoded_paths = [] + + def mock_quote(*args, **kwargs): + # The second frame is the call to repercent_broken_unicode(). + decoded_paths.append(inspect.currentframe().f_back.f_locals["path"]) + return quote(*args, **kwargs) + + with mock.patch("django.utils.encoding.quote", mock_quote): + self.assertEqual(repercent_broken_unicode(data), b"test%FCtest%FCtest%FC") + + # decode() is called on smaller fragment of the path each time. + self.assertEqual( + decoded_paths, + [b"test\xfctest\xfctest\xfc", b"test\xfctest\xfc", b"test\xfc"], + ) + class TestRFC3987IEncodingUtils(unittest.TestCase): diff -Nru python-django-3.2.19/tests/utils_tests/test_text.py python-django-3.2.25/tests/utils_tests/test_text.py --- python-django-3.2.19/tests/utils_tests/test_text.py 2023-05-03 11:57:58.000000000 +0000 +++ python-django-3.2.25/tests/utils_tests/test_text.py 2024-03-04 07:47:52.000000000 +0000 @@ -1,5 +1,6 @@ import json import sys +from unittest.mock import patch from django.core.exceptions import SuspiciousFileOperation from django.test import SimpleTestCase, ignore_warnings @@ -90,11 +91,17 @@ # lazy strings are handled correctly self.assertEqual(text.Truncator(lazystr('The quick brown fox')).chars(10), 'The quick…') - def test_truncate_chars_html(self): + @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 + bigger_len = text.Truncator.MAX_LENGTH_HTML + 1 + valid_html = "

    Joel is a slug

    " # 14 chars perf_test_values = [ - (('', None), - ('&' * 50000, '&' * 9 + '…'), - ('_X<<<<<<<<<<<>', None), + ("
    ", None), + ("", "", None), + (valid_html * bigger_len, "

    Joel is a…

    "), # 10 chars ] for value, expected in perf_test_values: with self.subTest(value=value): @@ -152,15 +159,51 @@ truncator = text.Truncator('

    I <3 python, what about you?

    ') self.assertEqual('

    I <3 python,…

    ', truncator.words(3, html=True)) + # Only open brackets. + test = "<" * 60_000 + truncator = text.Truncator(test) + self.assertEqual(truncator.words(1, html=True), test) + + # Tags with special chars in attrs. + truncator = text.Truncator( + """Hello, my dear lady!""" + ) + self.assertEqual( + """Hello, my dear…""", + truncator.words(3, html=True), + ) + + # Tags with special non-latin chars in attrs. + truncator = text.Truncator("""

    Hello, my dear lady!

    """) + self.assertEqual( + """

    Hello, my dear…

    """, + truncator.words(3, html=True), + ) + + # Misplaced brackets. + truncator = text.Truncator("hello >< world") + self.assertEqual(truncator.words(1, html=True), "hello…") + self.assertEqual(truncator.words(2, html=True), "hello >< world") + + @patch("django.utils.text.Truncator.MAX_LENGTH_HTML", 10_000) + def test_truncate_words_html_size_limit(self): + max_len = text.Truncator.MAX_LENGTH_HTML + bigger_len = text.Truncator.MAX_LENGTH_HTML + 1 + valid_html = "

    Joel is a slug

    " # 4 words perf_test_values = [ - ('', - '&' * 50000, - '_X<<<<<<<<<<<>', + ("", None), + ("", "", None), + (valid_html * bigger_len, valid_html * 12 + "

    Joel is…

    "), # 50 words ] - for value in perf_test_values: + for value, expected in perf_test_values: with self.subTest(value=value): truncator = text.Truncator(value) - self.assertEqual(value, truncator.words(50, html=True)) + self.assertEqual( + expected if expected else value, truncator.words(50, html=True) + ) def test_wrap(self): digits = '1234 67 9' diff -Nru python-django-3.2.19/tests/validators/tests.py python-django-3.2.25/tests/validators/tests.py --- python-django-3.2.19/tests/validators/tests.py 2023-05-03 11:57:58.000000000 +0000 +++ python-django-3.2.25/tests/validators/tests.py 2024-03-04 07:47:52.000000000 +0000 @@ -59,6 +59,7 @@ (validate_email, 'example@atm.%s' % ('a' * 64), ValidationError), (validate_email, 'example@%s.atm.%s' % ('b' * 64, 'a' * 63), ValidationError), + (validate_email, "example@%scom" % (("a" * 63 + ".") * 100), ValidationError), (validate_email, None, ValidationError), (validate_email, '', ValidationError), (validate_email, 'abc', ValidationError), @@ -246,6 +247,16 @@ (URLValidator(), None, ValidationError), (URLValidator(), 56, ValidationError), (URLValidator(), 'no_scheme', ValidationError), + ( + URLValidator(), + "http://example." + ("a" * 63 + ".") * 1000 + "com", + ValidationError, + ), + ( + URLValidator(), + "http://userid:password" + "d" * 2000 + "@example.aaaaaaaaaaaaa.com", + None, + ), # Newlines and tabs are not accepted. (URLValidator(), 'http://www.djangoproject.com/\n', ValidationError), (URLValidator(), 'http://[::ffff:192.9.5.5]\n', ValidationError),