티스토리 수익 글 보기

티스토리 수익 글 보기

[5.2.x] Fixed CVE-2026-25673 — Simplified URLField scheme detection. · django/django@4d3c184 · GitHub
Skip to content
/ django Public

Commit 4d3c184

Browse files
committed
[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 <jacobtylerwalls@gmail.com> Backport of 951ffb3 from main.
1 parent 94e7f17 commit 4d3c184

File tree

4 files changed

+145
29
lines changed

4 files changed

+145
29
lines changed

django/forms/fields.py

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import warnings
1414
from decimal import Decimal, DecimalException
1515
from io import BytesIO
16-
from urllib.parse import urlsplit, urlunsplit
1716

1817
from django.conf import settings
1918
from django.core import validators
@@ -797,33 +796,24 @@ def __init__(self, *, assume_scheme=None, **kwargs):
797796
super().__init__(strip=True, **kwargs)
798797

799798
def to_python(self, value):
800-
def split_url(url):
801-
"""
802-
Return a list of url parts via urlsplit(), or raise
803-
ValidationError for some malformed URLs.
804-
"""
805-
try:
806-
return list(urlsplit(url))
807-
except ValueError:
808-
# urlsplit can raise a ValueError with some
809-
# misformatted URLs.
810-
raise ValidationError(self.error_messages["invalid"], code="invalid")
811-
812799
value = super().to_python(value)
813800
if value:
814-
url_fields = split_url(value)
815-
if not url_fields[0]:
816-
# If no URL scheme given, add a scheme.
817-
url_fields[0] = self.assume_scheme
818-
if not url_fields[1]:
819-
# Assume that if no domain is provided, that the path segment
820-
# contains the domain.
821-
url_fields[1] = url_fields[2]
822-
url_fields[2] = ""
823-
# Rebuild the url_fields list, since the domain segment may now
824-
# contain the path too.
825-
url_fields = split_url(urlunsplit(url_fields))
826-
value = urlunsplit(url_fields)
801+
# Detect scheme via partition to avoid calling urlsplit() on
802+
# potentially large or slow-to-normalize inputs.
803+
scheme, sep, _ = value.partition(":")
804+
if (
805+
not sep
806+
or not scheme
807+
or not scheme[0].isascii()
808+
or not scheme[0].isalpha()
809+
or "/" in scheme
810+
):
811+
# No valid scheme found -- prepend the assumed scheme. Handle
812+
# scheme-relative URLs ("//example.com") separately.
813+
if value.startswith("//"):
814+
value = self.assume_scheme + ":" + value
815+
else:
816+
value = self.assume_scheme + "://" + value
827817
return value
828818

829819

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

tests/forms_tests/field_tests/test_urlfield.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33

44
from django.conf import FORMS_URLFIELD_ASSUME_HTTPS_DEPRECATED_MSG, Settings, settings
55
from django.core.exceptions import ValidationError
6+
from django.core.validators import URLValidator
67
from django.forms import URLField
7-
from django.test import SimpleTestCase, ignore_warnings
8+
from django.test import SimpleTestCase, ignore_warnings, override_settings
89
from django.utils.deprecation import RemovedInDjango60Warning
910

1011
from . import FormFieldAssertionsMixin
@@ -80,6 +81,16 @@ def test_urlfield_clean(self):
8081
# IPv6.
8182
("http://[12:34::3a53]/", "http://[12:34::3a53]/"),
8283
("http://[a34:9238::]:8080/", "http://[a34:9238::]:8080/"),
84+
# IPv6 without scheme.
85+
("[12:34::3a53]/", "https://[12:34::3a53]/"),
86+
# IDN domain without scheme but with port.
87+
("ñandú.es:8080/", "https://ñandú.es:8080/"),
88+
# Scheme-relative.
89+
("//example.com", "https://example.com"),
90+
("//example.com/path", "https://example.com/path"),
91+
# Whitespace stripped.
92+
("\t\n//example.com \n\t\n", "https://example.com"),
93+
("\t\nhttp://example.com \n\t\n", "http://example.com"),
8394
]
8495
for url, expected in tests:
8596
with self.subTest(url=url):
@@ -110,10 +121,19 @@ def test_urlfield_clean_invalid(self):
110121
# even on domains that don't fail the domain label length check in
111122
# the regex.
112123
"http://%s" % ("X" * 200,),
113-
# urlsplit() raises ValueError.
124+
# Scheme prepend yields a structurally invalid URL.
114125
"////]@N.AN",
115-
# Empty hostname.
126+
# Scheme prepend yields an empty hostname.
116127
"#@A.bO",
128+
# Known problematic unicode chars.
129+
"http://" + "¾" * 200,
130+
# Non-ASCII character before the first colon.
131+
"¾:example.com",
132+
# ASCII digit before the first colon.
133+
"1http://example.com",
134+
# Empty scheme.
135+
"://example.com",
136+
":example.com",
117137
]
118138
msg = "'Enter a valid URL.'"
119139
for value in tests:
@@ -154,6 +174,68 @@ def test_urlfield_assume_scheme(self):
154174
f = URLField(assume_scheme="https")
155175
self.assertEqual(f.clean("example.com"), "https://example.com")
156176

177+
@override_settings(FORMS_URLFIELD_ASSUME_HTTPS=True)
178+
def test_urlfield_assume_scheme_when_colons(self):
179+
f = URLField()
180+
tests = [
181+
# Port number.
182+
("http://example.com:8080/", "http://example.com:8080/"),
183+
("https://example.com:443/path", "https://example.com:443/path"),
184+
# Userinfo with password.
185+
("http://user:pass@example.com", "http://user:pass@example.com"),
186+
(
187+
"http://user:pass@example.com:8080/",
188+
"http://user:pass@example.com:8080/",
189+
),
190+
# Colon in path segment.
191+
("http://example.com/path:segment", "http://example.com/path:segment"),
192+
("http://example.com/a:b/c:d", "http://example.com/a:b/c:d"),
193+
# Colon in query string.
194+
("http://example.com/?key=val:ue", "http://example.com/?key=val:ue"),
195+
# Colon in fragment.
196+
("http://example.com/#section:1", "http://example.com/#section:1"),
197+
# IPv6 -- multiple colons in host.
198+
("http://[::1]/", "http://[::1]/"),
199+
("http://[2001:db8::1]/", "http://[2001:db8::1]/"),
200+
("http://[2001:db8::1]:8080/", "http://[2001:db8::1]:8080/"),
201+
# Colons across multiple components.
202+
(
203+
"http://user:pass@example.com:8080/path:x?q=a:b#id:1",
204+
"http://user:pass@example.com:8080/path:x?q=a:b#id:1",
205+
),
206+
# FTP with port and userinfo.
207+
(
208+
"ftp://user:pass@ftp.example.com:21/file",
209+
"ftp://user:pass@ftp.example.com:21/file",
210+
),
211+
(
212+
"ftps://user:pass@ftp.example.com:990/",
213+
"ftps://user:pass@ftp.example.com:990/",
214+
),
215+
# Scheme-relative URLs, starts with "//".
216+
("//example.com:8080/path", "https://example.com:8080/path"),
217+
("//user:pass@example.com/", "https://user:pass@example.com/"),
218+
]
219+
for value, expected in tests:
220+
with self.subTest(value=value):
221+
self.assertEqual(f.clean(value), expected)
222+
223+
def test_custom_validator_longer_max_length(self):
224+
225+
class CustomLongURLValidator(URLValidator):
226+
max_length = 4096
227+
228+
class CustomURLField(URLField):
229+
default_validators = [CustomLongURLValidator()]
230+
231+
field = CustomURLField()
232+
# A URL with 4096 chars is valid given the custom validator.
233+
prefix = "https://example.com/"
234+
url = prefix + "a" * (4096 - len(prefix))
235+
self.assertEqual(len(url), 4096)
236+
# No ValidationError is raised.
237+
field.clean(url)
238+
157239

158240
class URLFieldAssumeSchemeDeprecationTest(FormFieldAssertionsMixin, SimpleTestCase):
159241
def test_urlfield_raises_warning(self):

0 commit comments

Comments
 (0)