Explicitly strip zone ID from hostnames before matching certificates
Before Python 3.9 the ipaddress module didn't support scoped addresses
MODIFY
src/urllib3/connection.py
src/urllib3/connection.py
@@ -623,13 +623,15 @@ def _ssl_wrap_socket_and_match_hostname(
):
context.load_default_certs()
- # Our upstream implementation of ssl.match_hostname()
- # only applies this normalization to IP addresses so it doesn't
- # match DNS SANs so we do the same thing!
+ # Ensure that IPv6 addresses are in the proper format and don't have a
+ # scope ID. Python's SSL module fails to recognize scoped IPv6 addresses
+ # and interprets them as DNS hostnames.
if server_hostname is not None:
- stripped_hostname = server_hostname.strip("[]")
- if is_ipaddress(stripped_hostname):
- server_hostname = stripped_hostname
+ normalized = server_hostname.strip("[]")
+ if "%" in normalized:
+ normalized = normalized[: normalized.rfind("%")]
+ if is_ipaddress(normalized):
+ server_hostname = normalized
ssl_sock = ssl_wrap_socket(
sock=sock,

MODIFY
src/urllib3/util/ssl_match_hostname.py
src/urllib3/util/ssl_match_hostname.py
@@ -78,13 +78,16 @@ def _dnsname_match(
def _ipaddress_match(ipname: str, host_ip: Union[IPv4Address, IPv6Address]) -> bool:
"""Exact matching of IP addresses.
- RFC 6125 explicitly doesn't define an algorithm for this
- (section 1.7.2 - "Out of Scope").
+ RFC 9110 section 4.3.5: "A reference identity of IP-ID contains the decoded
+ bytes of the IP address. An IP version 4 address is 4 octets, and an IP
+ version 6 address is 16 octets. [...] A reference identity of type IP-ID
+ matches if the address is identical to an iPAddress value of the
+ subjectAltName extension of the certificate."
"""
# OpenSSL may add a trailing newline to a subjectAltName's IP address
# Divergence from upstream: ipaddress can't handle byte str
ip = ipaddress.ip_address(ipname.rstrip())
- return bool(ip == host_ip)
+ return bool(ip.packed == host_ip.packed)
def match_hostname(
@@ -107,7 +110,15 @@ def match_hostname(
)
try:
# Divergence from upstream: ipaddress can't handle byte str
+ #
+ # The ipaddress module shipped with Python < 3.9 does not support
+ # scoped IPv6 addresses so we unconditionally strip the Zone IDs for
+ # now. Once we drop support for Python 3.9 we can remove this branch.
+ if "%" in hostname:
+ host_ip = ipaddress.ip_address(hostname[: hostname.rfind("%")])
+ else:
host_ip = ipaddress.ip_address(hostname)
+
except ValueError:
# Not an IP address (common case)
host_ip = None

MODIFY
test/test_connection.py
test/test_connection.py
@@ -134,7 +134,24 @@ class TestConnection:
)
assert e._peer_cert == cert
- def test_match_hostname_ip_address_ipv6(self) -> None:
+ @pytest.mark.parametrize(
+ ["asserted_hostname", "san_ip"],
+ [
+ ("1:2::3:4", "1:2:0:0:0:0:3:4"),
+ ("1:2:0:0::3:4", "1:2:0:0:0:0:3:4"),
+ ("::0.1.0.2", "0:0:0:0:0:0:1:2"),
+ ("::1%42", "0:0:0:0:0:0:0:1"),
+ ("::2%iface", "0:0:0:0:0:0:0:2"),
+ ],
+ )
+ def test_match_hostname_ip_address_ipv6(
+ self, asserted_hostname: str, san_ip: str
+ ) -> None:
+ """Check that hostname matches follow RFC 9110 rules for IPv6."""
+ cert: "_TYPE_PEER_CERT_RET_DICT" = {"subjectAltName": (("IP Address", san_ip),)}
+ match_hostname(cert, asserted_hostname)
+
+ def test_match_hostname_ip_address_ipv6_doesnt_match(self) -> None:
cert: "_TYPE_PEER_CERT_RET_DICT" = {
"subjectAltName": (("IP Address", "1:2::2:1"),)
}

MODIFY
test/test_poolmanager.py
test/test_poolmanager.py
@@ -454,3 +454,17 @@ class TestPoolManager:
conn.connect()
assert create_connection.call_args[0][0] == ("a::b%zone", 80)
+
+ @patch("urllib3.connection.ssl_wrap_socket")
+ @patch("urllib3.util.connection.create_connection")
+ def test_e2e_connect_to_ipv6_scoped_tls(
+ self, create_connection: MagicMock, ssl_wrap_socket: MagicMock
+ ) -> None:
+ p = PoolManager()
+ conn_pool = p.connection_from_url(
+ "https://[a::b%zone]", pool_kwargs={"assert_hostname": False}
+ )
+ conn = conn_pool._get_conn()
+ conn.connect()
+
+ assert ssl_wrap_socket.call_args[1]["server_hostname"] == "a::b"

Explicitly strip zone ID from hostnames before matching certificates

Before Python 3.9 the ipaddress module didn't support scoped addresses

Co-authored-by: Seth Michael Larson <sethmichaellarson@gmail.com>