Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b3e8ec8

Browse files
committed
[4.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 <[email protected]> Backport of 951ffb3 from main.
1 parent e52ff00 commit b3e8ec8

3 files changed

Lines changed: 120 additions & 28 deletions

File tree

django/forms/fields.py

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import uuid
1313
from decimal import Decimal, DecimalException
1414
from io import BytesIO
15-
from urllib.parse import urlsplit, urlunsplit
1615

1716
from django.core import validators
1817
from django.core.exceptions import ValidationError
@@ -757,33 +756,24 @@ def __init__(self, **kwargs):
757756
super().__init__(strip=True, **kwargs)
758757

759758
def to_python(self, value):
760-
def split_url(url):
761-
"""
762-
Return a list of url parts via urlparse.urlsplit(), or raise
763-
ValidationError for some malformed URLs.
764-
"""
765-
try:
766-
return list(urlsplit(url))
767-
except ValueError:
768-
# urlparse.urlsplit can raise a ValueError with some
769-
# misformatted URLs.
770-
raise ValidationError(self.error_messages["invalid"], code="invalid")
771-
772759
value = super().to_python(value)
773760
if value:
774-
url_fields = split_url(value)
775-
if not url_fields[0]:
776-
# If no URL scheme given, assume http://
777-
url_fields[0] = "http"
778-
if not url_fields[1]:
779-
# Assume that if no domain is provided, that the path segment
780-
# contains the domain.
781-
url_fields[1] = url_fields[2]
782-
url_fields[2] = ""
783-
# Rebuild the url_fields list, since the domain segment may now
784-
# contain the path too.
785-
url_fields = split_url(urlunsplit(url_fields))
786-
value = urlunsplit(url_fields)
761+
# Detect scheme via partition to avoid calling urlsplit() on
762+
# potentially large or slow-to-normalize inputs.
763+
scheme, sep, _ = value.partition(":")
764+
if (
765+
not sep
766+
or not scheme
767+
or not scheme[0].isascii()
768+
or not scheme[0].isalpha()
769+
or "/" in scheme
770+
):
771+
# No valid scheme found -- prepend the assumed scheme. Handle
772+
# scheme-relative URLs ("//example.com") separately.
773+
if value.startswith("//"):
774+
value = "http:" + value
775+
else:
776+
value = "http://" + value
787777
return value
788778

789779

docs/releases/4.2.29.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,25 @@ Django 4.2.29 release notes
66

77
Django 4.2.29 fixes a security issue with severity "moderate" and a security
88
issue with severity "low" in 4.2.28.
9+
10+
CVE-2026-25673: Potential denial-of-service vulnerability in ``URLField`` via Unicode normalization on Windows
11+
==============================================================================================================
12+
13+
The :class:`~django.forms.URLField` form field's ``to_python()`` method used
14+
:func:`~urllib.parse.urlsplit` to determine whether to prepend a URL scheme to
15+
the submitted value. On Windows, ``urlsplit()`` performs
16+
:func:`NFKC normalization <python:unicodedata.normalize>`, which can be
17+
disproportionately slow for large inputs containing certain characters.
18+
19+
``URLField.to_python()`` now uses a simplified scheme detection, avoiding
20+
Unicode normalization entirely and deferring URL validation to the appropriate
21+
layers. As a result, while leading and trailing whitespace is still stripped by
22+
default, characters such as newlines, tabs, and other control characters within
23+
the value are no longer handled by ``URLField.to_python()``. When using the
24+
default :class:`~django.core.validators.URLValidator`, these values will
25+
continue to raise :exc:`~django.core.exceptions.ValidationError` during
26+
validation, but if you rely on custom validators, ensure they do not depend on
27+
the previous behavior of ``URLField.to_python()``.
28+
29+
This issue has severity "moderate" according to the :ref:`Django security
30+
policy <security-disclosure>`.

tests/forms_tests/field_tests/test_urlfield.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.core.exceptions import ValidationError
2+
from django.core.validators import URLValidator
23
from django.forms import URLField
34
from django.test import SimpleTestCase
45

@@ -72,6 +73,16 @@ def test_urlfield_clean(self):
7273
# IPv6.
7374
("http://[12:34::3a53]/", "http://[12:34::3a53]/"),
7475
("http://[a34:9238::]:8080/", "http://[a34:9238::]:8080/"),
76+
# IPv6 without scheme.
77+
("[12:34::3a53]/", "http://[12:34::3a53]/"),
78+
# IDN domain without scheme but with port.
79+
("ñandú.es:8080/", "http://ñandú.es:8080/"),
80+
# Scheme-relative.
81+
("//example.com", "http://example.com"),
82+
("//example.com/path", "http://example.com/path"),
83+
# Whitespace stripped.
84+
("\t\n//example.com \n\t\n", "http://example.com"),
85+
("\t\nhttp://example.com \n\t\n", "http://example.com"),
7586
]
7687
for url, expected in tests:
7788
with self.subTest(url=url):
@@ -102,10 +113,19 @@ def test_urlfield_clean_invalid(self):
102113
# even on domains that don't fail the domain label length check in
103114
# the regex.
104115
"http://%s" % ("X" * 200,),
105-
# urlsplit() raises ValueError.
116+
# Scheme prepend yields a structurally invalid URL.
106117
"////]@N.AN",
107-
# Empty hostname.
118+
# Scheme prepend yields an empty hostname.
108119
"#@A.bO",
120+
# Known problematic unicode chars.
121+
"http://" + "¾" * 200,
122+
# Non-ASCII character before the first colon.
123+
"¾:example.com",
124+
# ASCII digit before the first colon.
125+
"1http://example.com",
126+
# Empty scheme.
127+
"://example.com",
128+
":example.com",
109129
]
110130
msg = "'Enter a valid URL.'"
111131
for value in tests:
@@ -135,3 +155,63 @@ def test_urlfield_unable_to_set_strip_kwarg(self):
135155
msg = "__init__() got multiple values for keyword argument 'strip'"
136156
with self.assertRaisesMessage(TypeError, msg):
137157
URLField(strip=False)
158+
159+
def test_urlfield_assume_scheme_when_colons(self):
160+
f = URLField()
161+
tests = [
162+
# Port number.
163+
("http://example.com:8080/", "http://example.com:8080/"),
164+
("https://example.com:443/path", "https://example.com:443/path"),
165+
# Userinfo with password.
166+
("http://user:[email protected]", "http://user:[email protected]"),
167+
(
168+
"http://user:[email protected]:8080/",
169+
"http://user:[email protected]:8080/",
170+
),
171+
# Colon in path segment.
172+
("http://example.com/path:segment", "http://example.com/path:segment"),
173+
("http://example.com/a:b/c:d", "http://example.com/a:b/c:d"),
174+
# Colon in query string.
175+
("http://example.com/?key=val:ue", "http://example.com/?key=val:ue"),
176+
# Colon in fragment.
177+
("http://example.com/#section:1", "http://example.com/#section:1"),
178+
# IPv6 -- multiple colons in host.
179+
("http://[::1]/", "http://[::1]/"),
180+
("http://[2001:db8::1]/", "http://[2001:db8::1]/"),
181+
("http://[2001:db8::1]:8080/", "http://[2001:db8::1]:8080/"),
182+
# Colons across multiple components.
183+
(
184+
"http://user:[email protected]:8080/path:x?q=a:b#id:1",
185+
"http://user:[email protected]:8080/path:x?q=a:b#id:1",
186+
),
187+
# FTP with port and userinfo.
188+
(
189+
"ftp://user:[email protected]:21/file",
190+
"ftp://user:[email protected]:21/file",
191+
),
192+
(
193+
"ftps://user:[email protected]:990/",
194+
"ftps://user:[email protected]:990/",
195+
),
196+
# Scheme-relative URLs, starts with "//".
197+
("//example.com:8080/path", "http://example.com:8080/path"),
198+
("//user:[email protected]/", "http://user:[email protected]/"),
199+
]
200+
for value, expected in tests:
201+
with self.subTest(value=value):
202+
self.assertEqual(f.clean(value), expected)
203+
204+
def test_custom_validator_longer_max_length(self):
205+
class CustomLongURLValidator(URLValidator):
206+
max_length = 4096
207+
208+
class CustomURLField(URLField):
209+
default_validators = [CustomLongURLValidator()]
210+
211+
field = CustomURLField()
212+
# A URL with 4096 chars is valid given the custom validator.
213+
prefix = "https://example.com/"
214+
url = prefix + "a" * (4096 - len(prefix))
215+
self.assertEqual(len(url), 4096)
216+
# No ValidationError is raised.
217+
field.clean(url)

0 commit comments

Comments
 (0)