Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions Lib/test/test_urlparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ def test_urljoins(self):
self.checkJoin('a', 'b', 'b')

def test_RFC2732(self):
# IPv6 address
str_cases = [
('http://Test.python.org:5432/foo/', 'test.python.org', 5432),
('http://12.34.56.78:5432/foo/', '12.34.56.78', 5432),
Expand Down Expand Up @@ -481,24 +482,62 @@ def test_RFC2732(self):
('http://[::12.34.56.78]:/foo/', '::12.34.56.78', None),
('http://[::ffff:12.34.56.78]:/foo/',
'::ffff:12.34.56.78', None),
]
('http://[::]/', '::', None),
]
def _encode(t):
return t[0].encode('ascii'), t[1].encode('ascii'), t[2]
bytes_cases = [_encode(x) for x in str_cases]
for url, hostname, port in str_cases + bytes_cases:
urlparsed = urllib.parse.urlparse(url)
self.assertEqual((urlparsed.hostname, urlparsed.port) , (hostname, port))

# bpo-36338: reject invalid IPv6 addresses
str_cases = [
'http://::12.34.56.78]/',
'http://[::1/foo/',
'ftp://[::1/foo/bad]/bad',
'http://[::1/foo/bad]/bad',
'http://[::ffff:12.34.56.78']
# invalid IPv6 address
'http://[abcd:x::]/',
'http://[::1]example.com/',
# missing "[" or "]"
'http://::12.34.56.78]/',
'http://[::1/foo/',
'ftp://[::1/foo/bad]/bad',
'http://[::1/foo/bad]/bad',
'http://[::ffff:12.34.56.78',
# double ":"
'http://[::1]::80/',
# invalid "[" or "]" characters
'http://[[::1]]/',
'http://[::1][]/',
'http://[::1]:[]/',
'http://good.com[bad.com]',
'http://benign.com\\[attacker.com]',
]
bytes_cases = [x.encode('ascii') for x in str_cases]
for invalid_url in str_cases + bytes_cases:
self.assertRaises(ValueError, urllib.parse.urlparse, invalid_url)

def test_user_passwd(self):
# bpo-33342: username and password with "[" and "]" characters
for username in ("", "user", "[", "user[", "us]er", "us%er"):
for password in ("", "password", "p[ass]word", "]", "pa%%word"):
for url_host, hostname, port in (
("localhost", "localhost", None),
("127.0.0.1", "127.0.0.1", None),
("[::1]", '::1', None),
("[::1]:", '::1', None),
("[::1%scope]", '::1%scope', None),
("[::1]:443", '::1', 443),
):
netloc = f"{username}:{password}@{url_host}"
url = f"//{netloc}/path"
with self.subTest(url=url):
urlparsed = urllib.parse.urlparse(url)
self.assertEqual(urlparsed.netloc, netloc)
self.assertEqual(urlparsed.username, username)
self.assertEqual(urlparsed.password, password)
self.assertEqual(urlparsed.hostname, hostname)
self.assertEqual(urlparsed.port, port)
self.assertEqual(urlparsed.path, "/path")

def test_urldefrag(self):
str_cases = [
('http://python.org#frag', 'http://python.org', 'frag'),
Expand Down Expand Up @@ -531,6 +570,12 @@ def test_urlsplit_scoped_IPv6(self):
self.assertEqual(p.hostname, b"fe80::822a:a8ff:fe49:470c%tESt")
self.assertEqual(p.netloc, b'[FE80::822a:a8ff:fe49:470c%tESt]:1234')

for invalid_char in ('%', '[', ']'):
invalid_url = f'http://[::1%sco{invalid_char}pe]'
with self.subTest(invalid_url=invalid_url):
with self.assertRaises(ValueError):
urllib.parse.urlparse(invalid_url)

def test_urlsplit_attributes(self):
url = "HTTP://WWW.PYTHON.ORG/doc/#frag"
p = urllib.parse.urlsplit(url)
Expand Down Expand Up @@ -620,9 +665,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
with self.assertRaisesRegex(ValueError, "Port out of range 0-65535"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This slightly backwards incompatible but I am okay with the intention of the PR in validating port during parsing instead of accessing the port attribute since it just means the URL is invalid and is known earlier.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm checking the port to reject [ and ] in the port number. Reject port number outside the [0; 65535] is a side effect. IMHO it's a good thing to reject an invalid URL, no?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, to be more clear I am fine with the change. It's that previously port validation is done while accessing port attribute allowing invalid URL to be parsed but now it's done in parsing itself which is better as per this PR.

urllib.parse.urlsplit(url)

def test_attributes_bad_port(self):
"""Check handling of invalid ports."""
Expand Down
65 changes: 51 additions & 14 deletions Lib/urllib/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,7 @@ def hostname(self):
def port(self):
port = self._hostinfo[1]
if port is not None:
try:
port = int(port, 10)
except ValueError:
message = f'Port could not be cast to integer value as {port!r}'
raise ValueError(message) from None
if not ( 0 <= port <= 65535):
raise ValueError("Port out of range 0-65535")
port = _validate_port(port)
return port


Expand Down Expand Up @@ -388,17 +382,48 @@ def _splitparams(url):
i = url.find(';')
return url[:i], url[i+1:]

def _validate_port(port_str):
try:
port = int(port_str, 10)
except ValueError:
message = f'Port could not be cast to integer value as {port_str!r}'
raise ValueError(message) from None
if not (0 <= port <= 65535):
raise ValueError("Port out of range 0-65535: %r" % port_str)
return port

def _check_ipv6_host(host):
# "[ipv6]" host: ensure that it starts with "[", ends with "]"
# and that ipv6 doesn't contain "[" nor "]".
if not(host.startswith('[') and host.endswith(']')):
return False

parts = host[1:-1].split('%', 1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly offtopic but this just reminded me that ipaddress module doesn't support scope id in IPV6 address yet. Maybe once #13772 is merged we can just catch ValidationError and return False since the same validation would already be done in the parser to check for % in scope and remove validation here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowed characters in the scope part is not well defined. I read https://tools.ietf.org/html/rfc4007 and https://tools.ietf.org/html/rfc6874 RFC 6874:

A <zone_id> SHOULD contain only ASCII characters classified as
"unreserved" for use in URIs [RFC3986]. This excludes characters
such as "]" or even "%" that would complicate parsing. However, the
syntax described below does allow such characters to be percent-
encoded, for compatibility with existing devices that use them.

If an operating system uses any other characters in zone or interface
identifiers that are not in the "unreserved" character set, they MUST
be represented using percent encoding [RFC3986].

So... is % allowed in an unquoted URL?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, the syntax described below does allow such characters to be percent-
encoded, for compatibility with existing devices that use them.

@vstinner - I read the rfc6874 and the part that you quoted. If we want strict adherence to the RFC, I think, allowing % in unquoted URL is correct.

I looked at #13772 , which is trying to bring in the scope id to ipaddress module. There is an agreement and a documented statement that states: "If present, the scope ID must be non-empty, and may not contain %."

Let's keep the current change, and not allow '%', assuming that it wont be common for all practical purposes. It will be consistent within the standard library. If the allowance of '%' in scope-id is desired, that could be changed in ipaddress module, and once the ipaddress module scope-id support is merged, we could use the facility here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, allowing % in unquoted URL is correct.

Sorry but I'm not used to the urllib module. Is urllib.parse.urlsplit() supposed to get a "quoted" or "unquoted" URL?

@orsenthil orsenthil Oct 23, 2019

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is urllib.parse.urlsplit() supposed to get a "quoted" or "unquoted" URL?

It is supposed to get the unquoted URL. I relied on tests of urlparse to state this.


My reading of RFC 6874 and especially, the part quoted makes me think that 'percent-encoded' character like %40 or %25 could be present in the zone-id component beyond the first '%'. - If it is the case, the current implementation will False for it a valid IPv6 URL, but this is consistent with what ipaddress module return and it is documented by the ipaddress module.

I lack the experience to say something confidently about 'percent-encoded characters' in zone-id, and i think being consistent within modules and with documentation is the most appropriate thing to do.

I am +1 with committing this change. Please let me know you have hesitation or do you want (me/) us to research further.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. urllsplit accepts unquoted URl and returns the components of the URI. The change proposed in this patch and tests is doing this accurately.
  2. urlopen and open interface will quote the URL and percent-encode them for the special characters. (So, the examples of firefox and chromium presented in the discussion, I will expected those URIs to work in those browsers if they are percent-encoded.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@orsenthil , @vstinner In #13772 I come to the decision, that % character shall not be allowed in <zone_id> part according to paragraph 5 of Section 11.2 RFC 4007:

An implementation MAY support other kinds of non-null strings as
<zone_id>. However, the strings must not conflict with the delimiter
character
.

At the same time, Section 2 RFC 6874, especially paragraph 4, presumes, that <zone_id> part may contain % represented using percent encoding.

That is confusing and seems to conflict with RFC 4007.

That's why, I decided to assume, that RFC 6874 conserns only representing IPv6 Zone Identifiers in URI`s.

ipv6 = parts[0]
scope = (parts[1] if len(parts) > 1 else None)

import ipaddress
try:
ipaddress.IPv6Address(ipv6)
except ipaddress.AddressValueError:
return False

if scope is not None and any(char in '%[]' for char in scope):
return False

return True

def _splitnetloc(url, start=0):
delim = len(url) # position of end of domain part of url, default is end
for c in '/?#': # look for delimiters; the order is NOT important
wdelim = url.find(c, start) # find first of this delim
if wdelim >= 0: # if found
delim = min(delim, wdelim) # use earliest delim position
return url[start:delim], url[delim:] # return (domain, rest)
netloc = url[start:delim]
url = url[delim:]
return (netloc, url)

def _checknetloc(netloc):
if not netloc or netloc.isascii():
return
def _checknetloc_nfkc(netloc):
# looking for characters like \u2100 that expand to 'a/c'
# IDNA uses NFKC equivalence, so normalize for this check
import unicodedata
Expand All @@ -414,6 +439,21 @@ def _checknetloc(netloc):
raise ValueError("netloc '" + netloc + "' contains invalid " +
"characters under NFKC normalization")

def _checknetloc(netloc):
# Optimization: skip the check for empty string and ASCII-only strings.
# NFKC normalization has no effect on ASCII strings.
if netloc and not netloc.isascii():
_checknetloc_nfkc(netloc)
host, port = _splitport(netloc)
# don't validate "user:passwd@"
user_passwd, host = _splituser(host)
if '[' in host or ']' in host:
# "http://[ipv6%scope]:port/path" URL
if not _check_ipv6_host(host):
raise ValueError(f"Invalid network location: {netloc!r}")
if port is not None:
_validate_port(port)

def urlsplit(url, scheme='', allow_fragments=True):
"""Parse a URL into 5 components:
<scheme>://<netloc>/<path>?<query>#<fragment>
Expand All @@ -439,9 +479,6 @@ def urlsplit(url, scheme='', allow_fragments=True):

if url[:2] == '//':
netloc, url = _splitnetloc(url, 2)
if (('[' in netloc and ']' not in netloc) or
(']' in netloc and '[' not in netloc)):
raise ValueError("Invalid IPv6 URL")
if allow_fragments and '#' in url:
url, fragment = url.split('#', 1)
if '?' in url:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix :func:`urllib.parse.urlparse` for IPv6 address when user or password
contains "[" or "]" character.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The :mod:`urllib.urlparse` module now rejects invalid IPv6 addresses and
invalid port numbers when parsing an URL.