From c88f0787ea6704843d051799d7829a5fff21eef0 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 5 Aug 2026 10:16:18 +0300 Subject: [PATCH] gh-64470: Validate the port in urlsplit() and urlparse() urlsplit() and urlparse() now raise ValueError for an URL with an invalid port, instead of failing only when the port attribute of the result is read. This also rejects URLs with an unbracketed IPv6 address, like "http://::1/". Co-Authored-By: Claude Opus 5 (1M context) --- Doc/library/urllib.parse.rst | 10 ++++-- Lib/test/test_urllib.py | 12 ++++--- Lib/test/test_urlparse.py | 34 ++++++++++++++----- Lib/urllib/parse.py | 4 +++ ...6-08-05-11-20-00.gh-issue-64470.uRLprt.rst | 4 +++ 5 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-05-11-20-00.gh-issue-64470.uRLprt.rst diff --git a/Doc/library/urllib.parse.rst b/Doc/library/urllib.parse.rst index 26e159306c8107..4425c45cdcdd30 100644 --- a/Doc/library/urllib.parse.rst +++ b/Doc/library/urllib.parse.rst @@ -162,8 +162,9 @@ or on combining URL components into a URL string. .. [1] Depending on the value of the *missing_as_none* argument. - Reading the :attr:`port` attribute will raise a :exc:`ValueError` if - an invalid port is specified in the URL. See section + An invalid port specified in the URL will raise a :exc:`ValueError`. + Reading the :attr:`port` attribute of a result object created directly + will raise a :exc:`ValueError` too. See section :ref:`urlparse-result-object` for more information on the result object. Unmatched square brackets in the :attr:`netloc` attribute will raise a @@ -225,6 +226,11 @@ or on combining URL components into a URL string. .. versionchanged:: 3.15 Added the *missing_as_none* parameter. + .. versionchanged:: next + An invalid port now raises :exc:`ValueError` when the URL is parsed, + not only when the :attr:`~urllib.parse.SplitResult.port` attribute is + read. + .. _WHATWG spec: https://url.spec.whatwg.org/#concept-basic-url-parser diff --git a/Lib/test/test_urllib.py b/Lib/test/test_urllib.py index 1e5f79998e7cab..3fdc8f3c88b1bf 100644 --- a/Lib/test/test_urllib.py +++ b/Lib/test/test_urllib.py @@ -400,12 +400,16 @@ def test_url_host_with_newline_header_injection_rejected(self): host = "localhost\r\nX-injected: header\r\n" schemeless_url = "//" + host + ":8080/test/?test=a" try: - InvalidURL = http.client.InvalidURL - with self.assertRaisesRegex( - InvalidURL, r"contain control.*\\r"): + # The URL is rejected when it is parsed, because the injected + # header is not a valid port. + with self.assertRaises(ValueError): urllib.request.urlopen(f"http:{schemeless_url}") - with self.assertRaisesRegex(InvalidURL, r"contain control.*\\n"): + with self.assertRaises(ValueError): urllib.request.urlopen(f"https:{schemeless_url}") + # Such host is rejected by http.client as well. + with self.assertRaisesRegex(http.client.InvalidURL, + r"contain control.*\\r"): + http.client.HTTPConnection(host, 8080) finally: self.unfakehttp() diff --git a/Lib/test/test_urlparse.py b/Lib/test/test_urlparse.py index a5b7966c7780e9..a27c51dbca6051 100644 --- a/Lib/test/test_urlparse.py +++ b/Lib/test/test_urlparse.py @@ -917,9 +917,8 @@ def test_urlsplit_attributes(self): # Verify an illegal port raises ValueError url = b"HTTP://WWW.PYTHON.ORG:65536/doc/#frag" - p = urllib.parse.urlsplit(url) with self.assertRaisesRegex(ValueError, "out of range"): - p.port + urllib.parse.urlsplit(url) def test_urlsplit_remove_unsafe_bytes(self): # Remove ASCII tabs and newlines from input @@ -1029,11 +1028,32 @@ def test_attributes_bad_port(self, bytes, parse, port): self.skipTest('non-ASCII bytes') netloc = str_encode(netloc) url = str_encode(url) - p = parse(url) - self.assertEqual(p.netloc, netloc) + with self.assertRaises(ValueError): + parse(url) + # The port is still checked when it is read from a result + # constructed directly. + if bytes: + p = urllib.parse.SplitResultBytes(b'http', netloc, b'/', b'', b'') + else: + p = urllib.parse.SplitResult('http', netloc, '/', '', '') with self.assertRaises(ValueError): p.port + @support.subTests('parse', (urllib.parse.urlsplit, urllib.parse.urlparse)) + @support.subTests('netloc', ("::1", "a:b:c", "user@::1", "[::1]:80:80")) + def test_attributes_bad_netloc_port(self, parse, netloc): + """Check handling of a colon which does not delimit a valid port.""" + with self.assertRaises(ValueError): + parse("http://" + netloc + "/") + + @support.subTests('parse', (urllib.parse.urlsplit, urllib.parse.urlparse)) + @support.subTests('netloc', ("www.example.net", "www.example.net:", + "user:password@www.example.net", + "[::1]", "[::1]:80", "[::1]:")) + def test_attributes_good_port(self, parse, netloc): + """Check that valid netlocs are not rejected.""" + self.assertEqual(parse("http://" + netloc + "/").netloc, netloc) + @support.subTests('bytes', (False, True)) @support.subTests('parse', (urllib.parse.urlsplit, urllib.parse.urlparse)) @support.subTests('scheme', (".", "+", "-", "0", "http&", "६http")) @@ -1670,13 +1690,11 @@ def test_splitting_bracketed_hosts(self): def test_port_casting_failure_message(self): message = "Port could not be cast to integer value as 'oracle'" - p1 = urllib.parse.urlparse('http://Server=sde; Service=sde:oracle') with self.assertRaisesRegex(ValueError, message): - p1.port + urllib.parse.urlparse('http://Server=sde; Service=sde:oracle') - p2 = urllib.parse.urlsplit('http://Server=sde; Service=sde:oracle') with self.assertRaisesRegex(ValueError, message): - p2.port + urllib.parse.urlsplit('http://Server=sde; Service=sde:oracle') def test_telurl_params(self): p1 = urllib.parse.urlparse('tel:123-4;phone-context=+1-650-516') diff --git a/Lib/urllib/parse.py b/Lib/urllib/parse.py index 4247b9a4b07fa3..f76d84473b9005 100644 --- a/Lib/urllib/parse.py +++ b/Lib/urllib/parse.py @@ -477,6 +477,8 @@ def urlparse(url, scheme=None, allow_fragments=True, *, missing_as_none=_MISSING if query is None: query = '' if fragment is None: fragment = '' result = ParseResult(scheme, netloc, url, params, query, fragment) + if netloc and ':' in netloc: + result.port # check that the port is valid result = _coerce_result(result) result._keep_empty = missing_as_none return result @@ -586,6 +588,8 @@ def urlsplit(url, scheme=None, allow_fragments=True, *, missing_as_none=_MISSING if query is None: query = '' if fragment is None: fragment = '' result = SplitResult(scheme, netloc, url, query, fragment) + if netloc and ':' in netloc: + result.port # check that the port is valid result = _coerce_result(result) result._keep_empty = missing_as_none return result diff --git a/Misc/NEWS.d/next/Library/2026-08-05-11-20-00.gh-issue-64470.uRLprt.rst b/Misc/NEWS.d/next/Library/2026-08-05-11-20-00.gh-issue-64470.uRLprt.rst new file mode 100644 index 00000000000000..065901ddec9807 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-05-11-20-00.gh-issue-64470.uRLprt.rst @@ -0,0 +1,4 @@ +:func:`urllib.parse.urlsplit` and :func:`urllib.parse.urlparse` now raise +:exc:`ValueError` for URLs with an invalid port, instead of failing only when +the ``port`` attribute of the result is read. This also rejects URLs with an +unbracketed IPv6 address, like ``"http://::1/"``.