Skip to content

Commit 951ffb3

Browse files
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 <jacobtylerwalls@gmail.com>
1 parent c1d8646 commit 951ffb3

5 files changed

Lines changed: 165 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
@@ -780,33 +779,24 @@ def __init__(self, *, assume_scheme=None, **kwargs):
780779
super().__init__(strip=True, **kwargs)
781780

782781
def to_python(self, value):
783-
def split_url(url):
784-
"""
785-
Return a list of url parts via urlsplit(), or raise
786-
ValidationError for some malformed URLs.
787-
"""
788-
try:
789-
return list(urlsplit(url))
790-
except ValueError:
791-
# urlsplit can raise a ValueError with some
792-
# misformatted URLs.
793-
raise ValidationError(self.error_messages["invalid"], code="invalid")
794-
795782
value = super().to_python(value)
796783
if value:
797-
url_fields = split_url(value)
798-
if not url_fields[0]:
799-
# If no URL scheme given, add a scheme.
800-
url_fields[0] = self.assume_scheme
801-
if not url_fields[1]:
802-
# Assume that if no domain is provided, that the path segment
803-
# contains the domain.
804-
url_fields[1] = url_fields[2]
805-
url_fields[2] = ""
806-
# Rebuild the url_fields list, since the domain segment may now
807-
# contain the path too.
808-
url_fields = split_url(urlunsplit(url_fields))
809-
value = urlunsplit(url_fields)
784+
# Detect scheme via partition to avoid calling urlsplit() on
785+
# potentially large or slow-to-normalize inputs.
786+
scheme, sep, _ = value.partition(":")
787+
if (
788+
not sep
789+
or not scheme
790+
or not scheme[0].isascii()
791+
or not scheme[0].isalpha()
792+
or "/" in scheme
793+
):
794+
# No valid scheme found -- prepend the assumed scheme. Handle
795+
# scheme-relative URLs ("//example.com") separately.
796+
if value.startswith("//"):
797+
value = self.assume_scheme + ":" + value
798+
else:
799+
value = self.assume_scheme + "://" + value
810800
return value
811801

812802

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>`.

docs/releases/5.2.12.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ Django 5.2.12 fixes a security issue with severity "moderate" and a security
88
issue with severity "low" in 5.2.11. It also fixes one bug related to support
99
for Python 3.14.
1010

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

docs/releases/6.0.3.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ Django 6.0.3 release notes
77
Django 6.0.3 fixes a security issue with severity "moderate", a security issue
88
with severity "low", and several bugs in 6.0.2.
99

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>`.
31+
1032
Bugfixes
1133
========
1234

tests/forms_tests/field_tests/test_urlfield.py

Lines changed: 83 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]/", "https://[12:34::3a53]/"),
78+
# IDN domain without scheme but with port.
79+
("ñandú.es:8080/", "https://ñandú.es:8080/"),
80+
# Scheme-relative.
81+
("//example.com", "https://example.com"),
82+
("//example.com/path", "https://example.com/path"),
83+
# Whitespace stripped.
84+
("\t\n//example.com \n\t\n", "https://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:
@@ -143,3 +163,64 @@ def test_urlfield_assume_scheme(self):
143163
self.assertEqual(f.clean("example.com"), "http://example.com")
144164
f = URLField(assume_scheme="https")
145165
self.assertEqual(f.clean("example.com"), "https://example.com")
166+
167+
def test_urlfield_assume_scheme_when_colons(self):
168+
f = URLField()
169+
tests = [
170+
# Port number.
171+
("http://example.com:8080/", "http://example.com:8080/"),
172+
("https://example.com:443/path", "https://example.com:443/path"),
173+
# Userinfo with password.
174+
("http://user:pass@example.com", "http://user:pass@example.com"),
175+
(
176+
"http://user:pass@example.com:8080/",
177+
"http://user:pass@example.com:8080/",
178+
),
179+
# Colon in path segment.
180+
("http://example.com/path:segment", "http://example.com/path:segment"),
181+
("http://example.com/a:b/c:d", "http://example.com/a:b/c:d"),
182+
# Colon in query string.
183+
("http://example.com/?key=val:ue", "http://example.com/?key=val:ue"),
184+
# Colon in fragment.
185+
("http://example.com/#section:1", "http://example.com/#section:1"),
186+
# IPv6 -- multiple colons in host.
187+
("http://[::1]/", "http://[::1]/"),
188+
("http://[2001:db8::1]/", "http://[2001:db8::1]/"),
189+
("http://[2001:db8::1]:8080/", "http://[2001:db8::1]:8080/"),
190+
# Colons across multiple components.
191+
(
192+
"http://user:pass@example.com:8080/path:x?q=a:b#id:1",
193+
"http://user:pass@example.com:8080/path:x?q=a:b#id:1",
194+
),
195+
# FTP with port and userinfo.
196+
(
197+
"ftp://user:pass@ftp.example.com:21/file",
198+
"ftp://user:pass@ftp.example.com:21/file",
199+
),
200+
(
201+
"ftps://user:pass@ftp.example.com:990/",
202+
"ftps://user:pass@ftp.example.com:990/",
203+
),
204+
# Scheme-relative URLs, starts with "//".
205+
("//example.com:8080/path", "https://example.com:8080/path"),
206+
("//user:pass@example.com/", "https://user:pass@example.com/"),
207+
]
208+
for value, expected in tests:
209+
with self.subTest(value=value):
210+
self.assertEqual(f.clean(value), expected)
211+
212+
def test_custom_validator_longer_max_length(self):
213+
214+
class CustomLongURLValidator(URLValidator):
215+
max_length = 4096
216+
217+
class CustomURLField(URLField):
218+
default_validators = [CustomLongURLValidator()]
219+
220+
field = CustomURLField()
221+
# A URL with 4096 chars is valid given the custom validator.
222+
prefix = "https://example.com/"
223+
url = prefix + "a" * (4096 - len(prefix))
224+
self.assertEqual(len(url), 4096)
225+
# No ValidationError is raised.
226+
field.clean(url)

0 commit comments

Comments
 (0)