[2.0] Add tests for IPv6 proxy subjectAltNames
This PR is a forward-port of
MODIFY
src/urllib3/connection.py
src/urllib3/connection.py
@@ -54,6 +54,7 @@ from .util import SKIP_HEADER, SKIPPABLE_HEADERS, connection, ssl_
from .util.ssl_ import assert_fingerprint as _assert_fingerprint
from .util.ssl_ import (
create_urllib3_context,
+ is_ipaddress,
resolve_cert_reqs,
resolve_ssl_version,
ssl_wrap_socket,
@@ -606,6 +607,14 @@ 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!
+ if server_hostname is not None:
+ stripped_hostname = server_hostname.strip("[]")
+ if is_ipaddress(stripped_hostname):
+ server_hostname = stripped_hostname
+
ssl_sock = ssl_wrap_socket(
sock=sock,
keyfile=key_file,
@@ -656,6 +665,13 @@ def _match_hostname(
asserted_hostname: str,
hostname_checks_common_name: bool = False,
) -> None:
+ # 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!
+ stripped_hostname = asserted_hostname.strip("[]")
+ if is_ipaddress(stripped_hostname):
+ asserted_hostname = stripped_hostname
+
try:
match_hostname(cert, asserted_hostname, hostname_checks_common_name)
except CertificateError as e:

MODIFY
src/urllib3/util/ssl_match_hostname.py
src/urllib3/util/ssl_match_hostname.py
@@ -106,7 +106,7 @@ def match_hostname(
)
try:
# Divergence from upstream: ipaddress can't handle byte str
- host_ip = ipaddress.ip_address(hostname.strip("[]"))
+ host_ip = ipaddress.ip_address(hostname)
except ValueError:
# Not an IP address (common case)
host_ip = None

MODIFY
test/conftest.py
test/conftest.py
@@ -5,13 +5,14 @@ import ssl
import sys
import threading
from pathlib import Path
-from typing import AbstractSet, Any, Generator, NamedTuple, Optional
+from typing import AbstractSet, Any, Dict, Generator, NamedTuple, Optional, Tuple
import pytest
import trustme
from tornado import ioloop, web
from dummyserver.handlers import TestingApp
+from dummyserver.proxy import ProxyHandler
from dummyserver.server import HAS_IPV6, run_tornado_app
from dummyserver.testcase import HTTPSDummyServerTestCase
from urllib3.util import ssl_
@@ -42,6 +43,17 @@ class ServerConfig(NamedTuple):
return f"{self.scheme}://{host}:{self.port}"
+def _write_cert_to_dir(
+ cert: trustme.LeafCert, tmpdir: Path, file_prefix: str = "server"
+) -> Dict[str, str]:
+ cert_path = str(tmpdir / ("%s.pem" % file_prefix))
+ key_path = str(tmpdir / ("%s.key" % file_prefix))
+ cert.private_key_pem.write_to_path(key_path)
+ cert.cert_chain_pems[0].write_to_path(cert_path)
+ certs = {"keyfile": key_path, "certfile": cert_path}
+ return certs
+
+
@contextlib.contextmanager
def run_server_in_thread(
scheme: str, host: str, tmpdir: Path, ca: trustme.CA, server_cert: trustme.LeafCert
@@ -67,6 +79,44 @@ def run_server_in_thread(
server_thread.join()
+@contextlib.contextmanager
+def run_server_and_proxy_in_thread(
+ proxy_scheme: str,
+ proxy_host: str,
+ tmpdir: Path,
+ ca: trustme.CA,
+ proxy_cert: trustme.LeafCert,
+ server_cert: trustme.LeafCert,
+) -> Generator[Tuple[ServerConfig, ServerConfig], None, None]:
+ ca_cert_path = str(tmpdir / "ca.pem")
+ ca.cert_pem.write_to_path(ca_cert_path)
+
+ server_certs = _write_cert_to_dir(server_cert, tmpdir)
+ proxy_certs = _write_cert_to_dir(proxy_cert, tmpdir, "proxy")
+
+ io_loop = ioloop.IOLoop.current()
+ app = web.Application([(r".*", TestingApp)])
+ server_app, port = run_tornado_app(app, io_loop, server_certs, "https", "localhost")
+ server_config = ServerConfig("https", "localhost", port, ca_cert_path)
+
+ proxy = web.Application([(r".*", ProxyHandler)])
+ proxy_app, proxy_port = run_tornado_app(
+ proxy, io_loop, proxy_certs, proxy_scheme, proxy_host
+ )
+ proxy_config = ServerConfig(proxy_scheme, proxy_host, proxy_port, ca_cert_path)
+
+ server_thread = threading.Thread(target=io_loop.start)
+ server_thread.start()
+
+ yield (proxy_config, server_config)
+
+ io_loop.add_callback(server_app.stop)
+ io_loop.add_callback(proxy_app.stop)
+ io_loop.add_callback(io_loop.stop)
+
+ server_thread.join()
+
+
@pytest.fixture(params=["localhost", "127.0.0.1", "::1"])
def loopback_host(request: Any) -> Generator[str, None, None]:
host = request.param
@@ -112,6 +162,22 @@ def no_san_server_with_different_commmon_name(
yield cfg
+@pytest.fixture
+def no_san_proxy_with_server(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Generator[Tuple[ServerConfig, ServerConfig], None, None]:
+ tmpdir = tmp_path_factory.mktemp("certs")
+ ca = trustme.CA()
+ # only common name, no subject alternative names
+ proxy_cert = ca.issue_cert(common_name="localhost")
+ server_cert = ca.issue_cert("localhost")
+
+ with run_server_and_proxy_in_thread(
+ "https", "localhost", tmpdir, ca, proxy_cert, server_cert
+ ) as cfg:
+ yield cfg
+
+
@pytest.fixture
def no_localhost_san_server(
tmp_path_factory: pytest.TempPathFactory,
@@ -126,7 +192,41 @@ def no_localhost_san_server(
@pytest.fixture
-def ip_san_server(
+def ipv4_san_proxy_with_server(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Generator[Tuple[ServerConfig, ServerConfig], None, None]:
+ tmpdir = tmp_path_factory.mktemp("certs")
+ ca = trustme.CA()
+ # IP address in Subject Alternative Name
+ proxy_cert = ca.issue_cert("127.0.0.1")
+
+ server_cert = ca.issue_cert("localhost")
+
+ with run_server_and_proxy_in_thread(
+ "https", "127.0.0.1", tmpdir, ca, proxy_cert, server_cert
+ ) as cfg:
+ yield cfg
+
+
+@pytest.fixture
+def ipv6_san_proxy_with_server(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Generator[Tuple[ServerConfig, ServerConfig], None, None]:
+ tmpdir = tmp_path_factory.mktemp("certs")
+ ca = trustme.CA()
+ # IP addresses in Subject Alternative Name
+ proxy_cert = ca.issue_cert("::1")
+
+ server_cert = ca.issue_cert("localhost")
+
+ with run_server_and_proxy_in_thread(
+ "https", "::1", tmpdir, ca, proxy_cert, server_cert
+ ) as cfg:
+ yield cfg
+
+
+@pytest.fixture
+def ipv4_san_server(
tmp_path_factory: pytest.TempPathFactory,
) -> Generator[ServerConfig, None, None]:
tmpdir = tmp_path_factory.mktemp("certs")
@@ -154,6 +254,22 @@ def ipv6_san_server(
yield cfg
+@pytest.fixture
+def ipv6_no_san_server(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> Generator[ServerConfig, None, None]:
+ if not HAS_IPV6:
+ pytest.skip("Only runs on IPv6 systems")
+
+ tmpdir = tmp_path_factory.mktemp("certs")
+ ca = trustme.CA()
+ # IP address in Common Name
+ server_cert = ca.issue_cert(common_name="::1")
+
+ with run_server_in_thread("https", "::1", tmpdir, ca, server_cert) as cfg:
+ yield cfg
+
+
@pytest.fixture
def stub_timezone(request: pytest.FixtureRequest) -> Generator[None, None, None]:
"""

MODIFY
test/contrib/test_pyopenssl.py
test/contrib/test_pyopenssl.py
@@ -34,7 +34,8 @@ from ..test_ssl import TestSSL # noqa: E402, F401
from ..test_util import TestUtilSSL # noqa: E402, F401
from ..with_dummyserver.test_https import ( # noqa: E402, F401
TestHTTPS,
- TestHTTPS_Hostname,
+ TestHTTPS_IPV4SAN,
+ TestHTTPS_IPV6SAN,
TestHTTPS_TLSv1,
TestHTTPS_TLSv1_1,
TestHTTPS_TLSv1_2,

MODIFY
test/test_connection.py
test/test_connection.py
@@ -149,6 +149,21 @@ class TestConnection:
)
assert e._peer_cert == cert
+ def test_match_hostname_dns_with_brackets_doesnt_match(self) -> None:
+ cert: "_TYPE_PEER_CERT_RET_DICT" = {
+ "subjectAltName": (
+ ("DNS", "localhost"),
+ ("IP Address", "localhost"),
+ )
+ }
+ asserted_hostname = "[localhost]"
+ with pytest.raises(CertificateError) as e:
+ _match_hostname(cert, asserted_hostname)
+ assert (
+ "hostname '[localhost]' doesn't match either of 'localhost', 'localhost'"
+ in str(e.value)
+ )
+
def test_match_hostname_ip_address_ipv6_brackets(self) -> None:
cert: "_TYPE_PEER_CERT_RET_DICT" = {
"subjectAltName": (("IP Address", "1:2::2:1"),)

MODIFY
test/with_dummyserver/test_https.py
test/with_dummyserver/test_https.py
@@ -32,7 +32,7 @@ from dummyserver.server import (
encrypt_key_pem,
)
from dummyserver.testcase import HTTPSDummyServerTestCase
-from urllib3 import HTTPSConnectionPool, PoolManager
+from urllib3 import HTTPSConnectionPool
from urllib3.connection import RECENT_DATE, VerifiedHTTPSConnection
from urllib3.exceptions import (
ConnectTimeoutError,
@@ -1051,23 +1051,31 @@ class TestHTTPS_Hostname:
err.reason.args[0], (ssl.SSLCertVerificationError, CertificateError)
)
- def test_strip_square_brackets_before_validating(
- self, ipv6_san_server: ServerConfig
- ) -> None:
- """Test that the fix for #760 works."""
+
+class TestHTTPS_IPV4SAN:
+ def test_can_validate_ip_san(self, ipv4_san_server: ServerConfig) -> None:
+ """Ensure that urllib3 can validate SANs with IP addresses in them."""
with HTTPSConnectionPool(
- "[::1]",
- ipv6_san_server.port,
+ ipv4_san_server.host,
+ ipv4_san_server.port,
cert_reqs="CERT_REQUIRED",
- ca_certs=ipv6_san_server.ca_certs,
+ ca_certs=ipv4_san_server.ca_certs,
) as https_pool:
r = https_pool.request("GET", "/")
assert r.status == 200
- def test_request_from_poolmanager(self, san_server: ServerConfig) -> None:
- with PoolManager(
+
+class TestHTTPS_IPV6SAN:
+ @pytest.mark.parametrize("host", ["::1", "[::1]"])
+ def test_can_validate_ipv6_san(
+ self, ipv6_san_server: ServerConfig, host: str
+ ) -> None:
+ """Ensure that urllib3 can validate SANs with IPv6 addresses in them."""
+ with HTTPSConnectionPool(
+ host,
+ ipv6_san_server.port,
cert_reqs="CERT_REQUIRED",
- ca_certs=san_server.ca_certs,
- ) as pool:
- r = pool.request("GET", san_server.base_url)
+ ca_certs=ipv6_san_server.ca_certs,
+ ) as https_pool:
+ r = https_pool.request("GET", "/")
assert r.status == 200

MODIFY
test/with_dummyserver/test_proxy_poolmanager.py
test/with_dummyserver/test_proxy_poolmanager.py
@@ -4,6 +4,8 @@ import socket
import ssl
import tempfile
from test import LONG_TIMEOUT, SHORT_TIMEOUT, onlySecureTransport, withPyOpenSSL
+from test.conftest import ServerConfig
+from typing import Tuple
import pytest
import trustme
@@ -29,7 +31,6 @@ from urllib3.util.ssl_ import create_urllib3_context
from urllib3.util.timeout import Timeout
from .. import TARPIT_HOST, requires_network
-from ..conftest import ServerConfig
# Retry failed tests
pytestmark = pytest.mark.flaky
@@ -665,3 +666,68 @@ class TestHTTPSProxyVerification:
assert "hostname 'localhost' doesn't match" in str(
ssl_error
) or "Hostname mismatch" in str(ssl_error)
+
+ def test_https_proxy_ipv4_san(
+ self, ipv4_san_proxy_with_server: Tuple[ServerConfig, ServerConfig]
+ ) -> None:
+ proxy, server = ipv4_san_proxy_with_server
+ proxy_url = f"https://{proxy.host}:{proxy.port}"
+ destination_url = f"https://{server.host}:{server.port}"
+ with proxy_from_url(proxy_url, ca_certs=proxy.ca_certs) as https:
+ r = https.request("GET", destination_url)
+ assert r.status == 200
+
+ def test_https_proxy_ipv6_san(
+ self, ipv6_san_proxy_with_server: Tuple[ServerConfig, ServerConfig]
+ ) -> None:
+ proxy, server = ipv6_san_proxy_with_server
+ proxy_url = f"https://[{proxy.host}]:{proxy.port}"
+ destination_url = f"https://{server.host}:{server.port}"
+ with proxy_from_url(proxy_url, ca_certs=proxy.ca_certs) as https:
+ r = https.request("GET", destination_url)
+ assert r.status == 200
+
+ @pytest.mark.parametrize("target_scheme", ["http", "https"])
+ def test_https_proxy_no_san(
+ self,
+ no_san_proxy_with_server: Tuple[ServerConfig, ServerConfig],
+ target_scheme: str,
+ ) -> None:
+ proxy, server = no_san_proxy_with_server
+ proxy_url = f"https://{proxy.host}:{proxy.port}"
+ destination_url = f"{target_scheme}://{server.host}:{server.port}"
+
+ with proxy_from_url(proxy_url, ca_certs=proxy.ca_certs) as https:
+ with pytest.raises(MaxRetryError) as e:
+ https.request("GET", destination_url)
+ assert isinstance(e.value.reason, ProxyError)
+
+ ssl_error = e.value.reason.original_error
+ assert isinstance(ssl_error, SSLError)
+ assert "no appropriate subjectAltName fields were found" in str(
+ ssl_error
+ ) or "Hostname mismatch, certificate is not valid for 'localhost'" in str(
+ ssl_error
+ )
+
+ def test_https_proxy_no_san_hostname_checks_common_name(
+ self, no_san_proxy_with_server: Tuple[ServerConfig, ServerConfig]
+ ) -> None:
+ proxy, server = no_san_proxy_with_server
+ proxy_url = f"https://{proxy.host}:{proxy.port}"
+ destination_url = f"https://{server.host}:{server.port}"
+
+ proxy_ctx = urllib3.util.ssl_.create_urllib3_context()
+ try:
+ proxy_ctx.hostname_checks_common_name = True
+ # PyPy doesn't like us setting 'hostname_checks_common_name'
+ # but also has it enabled by default so we need to handle that.
+ except AttributeError:
+ pass
+ if getattr(proxy_ctx, "hostname_checks_common_name", False) is not True:
+ pytest.skip("Test requires 'SSLContext.hostname_checks_common_name=True'")
+
+ with proxy_from_url(
+ proxy_url, ca_certs=proxy.ca_certs, proxy_ssl_context=proxy_ctx
+ ) as https:
+ https.request("GET", destination_url)

[2.0] Add tests for IPv6 proxy subjectAltNames

This PR is a forward-port of #2419