From 4d3c184686626d224d9a87451410ecf802b41f7c Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:52:41 -0300 Subject: [5.2.x] Fixed CVE-2026-25673 -- Simplified URLField scheme detection. This simplicaftion mitigates a potential DoS in URLField on Windows. The usage of `urlsplit()` in `URLField.to_python()` was replaced with `str.partition(":")` for URL scheme detection. On Windows, `urlsplit()` performs Unicode normalization which is slow for certain characters, making `URLField` vulnerable to DoS via specially crafted POST payloads. Thanks Seokchan Yoon for the report, and Jake Howard and Shai Berger for the review. Refs #36923. Co-authored-by: Jacob Walls Backport of 951ffb3832cd83ba672c1e3deae2bda128eb9cca from main. --- django/forms/fields.py | 42 +++++------- docs/releases/4.2.29.txt | 22 +++++++ docs/releases/5.2.12.txt | 22 +++++++ tests/forms_tests/field_tests/test_urlfield.py | 88 +++++++++++++++++++++++++- 4 files changed, 145 insertions(+), 29 deletions(-) diff --git a/django/forms/fields.py b/django/forms/fields.py index 4bd9c352f2..14e207a257 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -13,7 +13,6 @@ import uuid import warnings from decimal import Decimal, DecimalException from io import BytesIO -from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.core import validators @@ -797,33 +796,24 @@ class URLField(CharField): super().__init__(strip=True, **kwargs) def to_python(self, value): - def split_url(url): - """ - Return a list of url parts via urlsplit(), or raise - ValidationError for some malformed URLs. - """ - try: - return list(urlsplit(url)) - except ValueError: - # urlsplit can raise a ValueError with some - # misformatted URLs. - raise ValidationError(self.error_messages["invalid"], code="invalid") - value = super().to_python(value) if value: - url_fields = split_url(value) - if not url_fields[0]: - # If no URL scheme given, add a scheme. - url_fields[0] = self.assume_scheme - if not url_fields[1]: - # Assume that if no domain is provided, that the path segment - # contains the domain. - url_fields[1] = url_fields[2] - url_fields[2] = "" - # Rebuild the url_fields list, since the domain segment may now - # contain the path too. - url_fields = split_url(urlunsplit(url_fields)) - value = urlunsplit(url_fields) + # Detect scheme via partition to avoid calling urlsplit() on + # potentially large or slow-to-normalize inputs. + scheme, sep, _ = value.partition(":") + if ( + not sep + or not scheme + or not scheme[0].isascii() + or not scheme[0].isalpha() + or "/" in scheme + ): + # No valid scheme found -- prepend the assumed scheme. Handle + # scheme-relative URLs ("//example.com") separately. + if value.startswith("//"): + value = self.assume_scheme + ":" + value + else: + value = self.assume_scheme + "://" + value return value diff --git a/docs/releases/4.2.29.txt b/docs/releases/4.2.29.txt index a3f3787cd6..b780264929 100644 --- a/docs/releases/4.2.29.txt +++ b/docs/releases/4.2.29.txt @@ -6,3 +6,25 @@ Django 4.2.29 release notes Django 4.2.29 fixes a security issue with severity "moderate" and a security issue with severity "low" in 4.2.28. + +CVE-2026-25673: Potential denial-of-service vulnerability in ``URLField`` via Unicode normalization on Windows +============================================================================================================== + +The :class:`~django.forms.URLField` form field's ``to_python()`` method used +:func:`~urllib.parse.urlsplit` to determine whether to prepend a URL scheme to +the submitted value. On Windows, ``urlsplit()`` performs +:func:`NFKC normalization `, which can be +disproportionately slow for large inputs containing certain characters. + +``URLField.to_python()`` now uses a simplified scheme detection, avoiding +Unicode normalization entirely and deferring URL validation to the appropriate +layers. As a result, while leading and trailing whitespace is still stripped by +default, characters such as newlines, tabs, and other control characters within +the value are no longer handled by ``URLField.to_python()``. When using the +default :class:`~django.core.validators.URLValidator`, these values will +continue to raise :exc:`~django.core.exceptions.ValidationError` during +validation, but if you rely on custom validators, ensure they do not depend on +the previous behavior of ``URLField.to_python()``. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. diff --git a/docs/releases/5.2.12.txt b/docs/releases/5.2.12.txt index 9cbbf3836a..be2c7bc807 100644 --- a/docs/releases/5.2.12.txt +++ b/docs/releases/5.2.12.txt @@ -8,6 +8,28 @@ Django 5.2.12 fixes a security issue with severity "moderate" and a security issue with severity "low" in 5.2.11. It also fixes one bug related to support for Python 3.14. +CVE-2026-25673: Potential denial-of-service vulnerability in ``URLField`` via Unicode normalization on Windows +============================================================================================================== + +The :class:`~django.forms.URLField` form field's ``to_python()`` method used +:func:`~urllib.parse.urlsplit` to determine whether to prepend a URL scheme to +the submitted value. On Windows, ``urlsplit()`` performs +:func:`NFKC normalization `, which can be +disproportionately slow for large inputs containing certain characters. + +``URLField.to_python()`` now uses a simplified scheme detection, avoiding +Unicode normalization entirely and deferring URL validation to the appropriate +layers. As a result, while leading and trailing whitespace is still stripped by +default, characters such as newlines, tabs, and other control characters within +the value are no longer handled by ``URLField.to_python()``. When using the +default :class:`~django.core.validators.URLValidator`, these values will +continue to raise :exc:`~django.core.exceptions.ValidationError` during +validation, but if you rely on custom validators, ensure they do not depend on +the previous behavior of ``URLField.to_python()``. + +This issue has severity "moderate" according to the :ref:`Django security +policy `. + Bugfixes ======== diff --git a/tests/forms_tests/field_tests/test_urlfield.py b/tests/forms_tests/field_tests/test_urlfield.py index 54bc7c5e46..88ee267b1e 100644 --- a/tests/forms_tests/field_tests/test_urlfield.py +++ b/tests/forms_tests/field_tests/test_urlfield.py @@ -3,8 +3,9 @@ from types import ModuleType from django.conf import FORMS_URLFIELD_ASSUME_HTTPS_DEPRECATED_MSG, Settings, settings from django.core.exceptions import ValidationError +from django.core.validators import URLValidator from django.forms import URLField -from django.test import SimpleTestCase, ignore_warnings +from django.test import SimpleTestCase, ignore_warnings, override_settings from django.utils.deprecation import RemovedInDjango60Warning from . import FormFieldAssertionsMixin @@ -80,6 +81,16 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase): # IPv6. ("http://[12:34::3a53]/", "http://[12:34::3a53]/"), ("http://[a34:9238::]:8080/", "http://[a34:9238::]:8080/"), + # IPv6 without scheme. + ("[12:34::3a53]/", "https://[12:34::3a53]/"), + # IDN domain without scheme but with port. + ("ñandú.es:8080/", "https://ñandú.es:8080/"), + # Scheme-relative. + ("//example.com", "https://example.com"), + ("//example.com/path", "https://example.com/path"), + # Whitespace stripped. + ("\t\n//example.com \n\t\n", "https://example.com"), + ("\t\nhttp://example.com \n\t\n", "http://example.com"), ] for url, expected in tests: with self.subTest(url=url): @@ -110,10 +121,19 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase): # even on domains that don't fail the domain label length check in # the regex. "http://%s" % ("X" * 200,), - # urlsplit() raises ValueError. + # Scheme prepend yields a structurally invalid URL. "////]@N.AN", - # Empty hostname. + # Scheme prepend yields an empty hostname. "#@A.bO", + # Known problematic unicode chars. + "http://" + "¾" * 200, + # Non-ASCII character before the first colon. + "¾:example.com", + # ASCII digit before the first colon. + "1http://example.com", + # Empty scheme. + "://example.com", + ":example.com", ] msg = "'Enter a valid URL.'" for value in tests: @@ -154,6 +174,68 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase): f = URLField(assume_scheme="https") self.assertEqual(f.clean("example.com"), "https://example.com") + @override_settings(FORMS_URLFIELD_ASSUME_HTTPS=True) + def test_urlfield_assume_scheme_when_colons(self): + f = URLField() + tests = [ + # Port number. + ("http://example.com:8080/", "http://example.com:8080/"), + ("https://example.com:443/path", "https://example.com:443/path"), + # Userinfo with password. + ("http://user:pass@example.com", "http://user:pass@example.com"), + ( + "http://user:pass@example.com:8080/", + "http://user:pass@example.com:8080/", + ), + # Colon in path segment. + ("http://example.com/path:segment", "http://example.com/path:segment"), + ("http://example.com/a:b/c:d", "http://example.com/a:b/c:d"), + # Colon in query string. + ("http://example.com/?key=val:ue", "http://example.com/?key=val:ue"), + # Colon in fragment. + ("http://example.com/#section:1", "http://example.com/#section:1"), + # IPv6 -- multiple colons in host. + ("http://[::1]/", "http://[::1]/"), + ("http://[2001:db8::1]/", "http://[2001:db8::1]/"), + ("http://[2001:db8::1]:8080/", "http://[2001:db8::1]:8080/"), + # Colons across multiple components. + ( + "http://user:pass@example.com:8080/path:x?q=a:b#id:1", + "http://user:pass@example.com:8080/path:x?q=a:b#id:1", + ), + # FTP with port and userinfo. + ( + "ftp://user:pass@ftp.example.com:21/file", + "ftp://user:pass@ftp.example.com:21/file", + ), + ( + "ftps://user:pass@ftp.example.com:990/", + "ftps://user:pass@ftp.example.com:990/", + ), + # Scheme-relative URLs, starts with "//". + ("//example.com:8080/path", "https://example.com:8080/path"), + ("//user:pass@example.com/", "https://user:pass@example.com/"), + ] + for value, expected in tests: + with self.subTest(value=value): + self.assertEqual(f.clean(value), expected) + + def test_custom_validator_longer_max_length(self): + + class CustomLongURLValidator(URLValidator): + max_length = 4096 + + class CustomURLField(URLField): + default_validators = [CustomLongURLValidator()] + + field = CustomURLField() + # A URL with 4096 chars is valid given the custom validator. + prefix = "https://example.com/" + url = prefix + "a" * (4096 - len(prefix)) + self.assertEqual(len(url), 4096) + # No ValidationError is raised. + field.clean(url) + class URLFieldAssumeSchemeDeprecationTest(FormFieldAssertionsMixin, SimpleTestCase): def test_urlfield_raises_warning(self): -- cgit v1.3