From 5547eef38da6b9b6966b9c5ad189d5e539800f93 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 13 Apr 2026 13:28:55 +0700 Subject: [PATCH] feat: Support IPv6 zone IDs (RFC 6874) in URL parsing Add correct handling of IPv6 scope/zone identifiers (e.g. fe80::1%25eth0) across HTTPAdapter and PreparedRequest. - Add _has_ipv6_zone_id() helper in adapters.py using a regex anchored to the URL authority section to reliably detect zone IDs without false positives - Update _urllib3_request_context() to use urllib3 parse_url (instead of urlparse) when a zone ID is present, ensuring scheme, port, and hostname are extracted correctly for IPv6 scoped addresses - Update HTTPAdapter scheme resolution to use parse_url for zone ID URLs - Add RFC 6874 zone ID regex patterns in models.py (_AUTHORITY_BRACKET_RE, _RFC6874_ZONE_ID_RE, _RAW_ZONE_ID_RE) - Fix PreparedRequest.prepare_url() to preserve RFC 6874 encoded zone IDs (%25 delimiter) verbatim and re-encode raw % zone IDs (legacy form) as %25 to prevent downstream ipaddress validation and connection errors --- src/requests/adapters.py | 65 +++++- src/requests/models.py | 44 ++++ tests/test_adapters.py | 429 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 533 insertions(+), 5 deletions(-) diff --git a/src/requests/adapters.py b/src/requests/adapters.py index 98f74465f2..5a2ef40a68 100644 --- a/src/requests/adapters.py +++ b/src/requests/adapters.py @@ -7,6 +7,7 @@ """ import os.path +import re import socket # noqa: F401 import typing import warnings @@ -73,6 +74,45 @@ def SOCKSProxyManager(*args, **kwargs): DEFAULT_RETRIES = 0 DEFAULT_POOL_TIMEOUT = None +# Anchored to the authority section of the URL (between "://" and the first +# "/", "?", or "#") so that brackets in the path or query string cannot +# produce false positives. +# +# Inside the brackets two forms are detected: +# - RFC 6874 encoded %25: the delimiter is %25 followed by one or more +# ZoneID characters. Per RFC 6874 the ZoneID unreserved chars are +# [A-Za-z0-9_.\-~] plus percent-encoded octets (%[0-9A-Fa-f]{2}), so +# names like "Ethernet%203" (space encoded as %20) or names containing +# tildes are matched correctly. +# - Literal %: a negative lookahead (?![0-9A-Fa-f]{2}) rejects valid +# percent-encoded bytes whose first hex digit happens to be a letter +# (e.g. %AB, %aF, %CD). After that guard, one alphanumeric character +# is required (covering both named interfaces like eth0 and numeric +# zone indices like 1 or 3), followed by zero or more identifier chars. +_IPV6_ZONE_ID_RE = re.compile( + r"://[^/?#]*\[[^\]]*" + r"(?:%25(?:[a-zA-Z0-9_.\-~]|%[0-9A-Fa-f]{2})+" + r"|%(?![0-9A-Fa-f]{2})[0-9A-Za-z][A-Za-z0-9_.\-]*)\]" +) + + +def _has_ipv6_zone_id(url: str) -> bool: + """ + Detect if URL contains IPv6 zone identifier (scope ID). + + IPv6 zone IDs use % character within brackets, e.g.: + http://[fe80::1%eth0]:8080/ + + This is used to determine whether to use urllib3's parse_url() + (which handles zone IDs correctly) or urlparse() for backward + compatibility. + + :param url: URL string to check + :return: True if URL contains IPv6 zone ID + :rtype: bool + """ + return bool(_IPV6_ZONE_ID_RE.search(url)) + def _urllib3_request_context( request: "PreparedRequest", @@ -82,9 +122,21 @@ def _urllib3_request_context( ) -> "(dict[str, typing.Any], dict[str, typing.Any])": host_params = {} pool_kwargs = {} - parsed_request_url = urlparse(request.url) - scheme = parsed_request_url.scheme.lower() - port = parsed_request_url.port + + # Use urllib3's parse_url for IPv6 zone IDs, urlparse otherwise + if _has_ipv6_zone_id(request.url): + parsed_request_url = parse_url(request.url) + scheme = parsed_request_url.scheme.lower() + port = parsed_request_url.port + # parse_url uses .host and includes brackets for IPv6, strip them + hostname = parsed_request_url.host + if hostname and hostname.startswith("[") and hostname.endswith("]"): + hostname = hostname[1:-1] + else: + parsed_request_url = urlparse(request.url) + scheme = parsed_request_url.scheme.lower() + port = parsed_request_url.port + hostname = parsed_request_url.hostname # urlparse uses .hostname cert_reqs = "CERT_REQUIRED" if verify is False: @@ -105,7 +157,7 @@ def _urllib3_request_context( pool_kwargs["cert_file"] = client_cert host_params = { "scheme": scheme, - "host": parsed_request_url.hostname, + "host": hostname, "port": port, } return host_params, pool_kwargs @@ -536,7 +588,10 @@ def request_url(self, request, proxies): :rtype: str """ proxy = select_proxy(request.url, proxies) - scheme = urlparse(request.url).scheme + if _has_ipv6_zone_id(request.url): + scheme = parse_url(request.url).scheme + else: + scheme = urlparse(request.url).scheme is_proxied_http_request = proxy and scheme != "https" using_socks_proxy = False diff --git a/src/requests/models.py b/src/requests/models.py index 2d043f59cf..fe60d22463 100644 --- a/src/requests/models.py +++ b/src/requests/models.py @@ -11,6 +11,7 @@ # Implicit import within threads may cause LookupError when standard library is in a ZIP, # such as in Embedded Python. See https://github.com/psf/requests/issues/3578. import encodings.idna # noqa: F401 +import re from io import UnsupportedOperation from urllib3.exceptions import ( @@ -82,6 +83,14 @@ CONTENT_CHUNK_SIZE = 10 * 1024 ITER_CHUNK_SIZE = 512 +# Regex patterns for IPv6 zone ID handling in prepare_url. +# Extracts the bracket content from the authority section of the URL. +_AUTHORITY_BRACKET_RE = re.compile(r"://[^/?#]*\[([^\]]*)\]") +# Matches an RFC 6874 zone ID delimiter (%25) followed by zone ID characters. +_RFC6874_ZONE_ID_RE = re.compile(r"%25(?:[a-zA-Z0-9_.\-~]|%[0-9A-Fa-f]{2})+") +# Matches a raw % zone ID delimiter (not a valid percent-encoded byte). +_RAW_ZONE_ID_RE = re.compile(r"%(?![0-9A-Fa-f]{2})[0-9A-Za-z][A-Za-z0-9_.\-]*") + class RequestEncodingMixin: @property @@ -436,6 +445,41 @@ def prepare_url(self, url, params): except LocationParseError as e: raise InvalidURL(*e.args) + # Mitigation for RFC 6874: parse_url incorrectly decodes zone ID delimiter (%25 -> %) + # We reconstruct the host with the correct, fully-encoded delimiter to prevent + # downstream errors (like ipaddress validation or incorrect connection arguments). + # + # Matching on the parse_url-decoded host is ambiguous because parse_url decodes + # %25 -> % and then the resulting %XX may look like a valid percent-encoding + # (e.g. %2550 becomes %50 which resembles percent-encoded 'P'). Instead we + # extract the bracket content from the ORIGINAL url (before any decoding) and + # match there. Two input forms are handled: + # + # 1. RFC 6874 encoded form (%25 delimiter): the original bracket contains %25 + # followed by one or more ZoneID unreserved chars ([A-Za-z0-9_.\-~]) or + # pct-encoded octets (%XX). Examples: [fe80::1%25eth0], [fe80::1%255], + # [fe80::1%25_foo]. The matched segment is placed verbatim into host. + # + # 2. Raw % delimiter (legacy/non-standard): a literal % that is NOT a valid + # %XX percent-encoding, followed by a letter then more identifier chars. + # Examples: [fe80::1%eth0], [fe80::1%wlan0]. Re-encoded as %25. + # + # This avoids false-positive re-encoding of legitimate %XX sequences (e.g. %20, + # %AB) that should never be treated as zone ID delimiters. + if host and host.startswith("[") and host.endswith("]"): + original_bracket = _AUTHORITY_BRACKET_RE.search(url) + if original_bracket: + original_inner = original_bracket.group(1) + rfc_match = _RFC6874_ZONE_ID_RE.search(original_inner) + if rfc_match: + ip_part = original_inner[: rfc_match.start()] + host = f"[{ip_part}{rfc_match.group()}]" + else: + raw_match = _RAW_ZONE_ID_RE.search(original_inner) + if raw_match: + pos = raw_match.start() + host = f"[{original_inner[:pos]}%25{original_inner[pos + 1 :]}]" + if not scheme: raise MissingSchema( f"Invalid URL {url!r}: No scheme supplied. " diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 6c55d5a130..a77d039fde 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,4 +1,12 @@ +import sys +from unittest.mock import MagicMock + +import pytest +from urllib3 import HTTPResponse + +import requests import requests.adapters +from requests.adapters import _has_ipv6_zone_id def test_request_url_trims_leading_path_separators(): @@ -6,3 +14,424 @@ def test_request_url_trims_leading_path_separators(): a = requests.adapters.HTTPAdapter() p = requests.Request(method="GET", url="http://127.0.0.1:10000//v:h").prepare() assert "/v:h" == a.request_url(p, {}) + + +class TestIPv6ZoneIDDetection: + """Test the helper function that detects IPv6 zone identifiers.""" + + @pytest.mark.parametrize( + ("url", "has_zone_id"), + [ + # URLs with IPv6 zone identifiers + ("http://[fe80::1%eth0]:8080/", True), + ("http://[fe80::5054:ff:fe5a:fc0%enp1s0]:80/", True), + ("http://[fe80::1%25eth0]:8080/", True), # URL-encoded % + ("https://[fe80::1%lo]/path", True), + ("http://[2001:db8::1%eth0]:443/", True), + # URLs without zone identifiers + ("http://[fe80::1]:8080/", False), + ("http://[1200:0000:ab00:1234:0000:2552:7777:1313]:12345/", False), + ("http://[::1]/", False), + ("http://example.com:8080/", False), + ("http://192.168.1.1:8080/", False), + ("https://google.com/", False), + ("http://localhost/", False), + ("http://example.com/foo%20bar", False), # % in path, not zone ID + ("http://[::1]/path%20with%20percent", False), # % in path, not in host + # False-positive guard: percent-encoded chars inside brackets are NOT zone IDs + ("http://[::1%20]/", False), # %20 = space encoding, not a zone ID + ("http://[::1%2F]/", False), # %2F = slash encoding, not a zone ID + ("http://[::1%2B]/", False), # %2B = plus encoding, not a zone ID + ("http://[::1%41]/", False), # %41 = 'A', two hex digits, not a zone ID + ("http://[fe80::1%20]:8080/", False), # %20 in host with port + ( + "http://[::1%25]/", + False, + ), # bare %25 with nothing after it is not a zone ID + # Edge cases with multiple percent signs + ("http://[fe80::1%eth0]/path%20test", True), # Zone ID + path encoding + ( + "http://[fe80::1%25eth0]/path%20test", + True, + ), # %25 zone ID + path encoding + ("http://[::1]/query?param=%20value", False), # % in query, not zone ID + ( + "http://[::1]:8080/path%20with%20multiple%20percents", + False, + ), # Multiple % in path + # Additional %25 encoding tests + ("http://[fe80::1%25lo]:9090/", True), # %25 with different port + ("https://[2001:db8::1%25wlan0]:443/path", True), # HTTPS with %25 zone ID + # Brackets in path/query must NOT trigger zone-ID detection (Bug 1 guard) + ("http://example.com/api/[data%25value]", False), # brackets in path + ( + "http://[::1]/path/[data%25value]", + False, + ), # brackets in path after real host + ("http://example.com/search?q=[tag%25foo]", False), # brackets in query + # Hex-letter percent-encoded bytes inside host brackets are NOT zone IDs (Bug 2 guard) + ("http://[::1%AB]/", False), # %AB = valid hex byte, not a zone ID + ( + "http://[::1%aF]/", + False, + ), # %aF = valid hex byte (mixed case), not a zone ID + ("http://[::1%CD]/", False), # %CD = valid hex byte, not a zone ID + ("http://[::1%EF]/", False), # %EF = valid hex byte, not a zone ID + ("http://[fe80::1%AB]:8080/", False), # %AB with port, still not a zone ID + # Zone IDs whose names contain percent-encoded characters (e.g. spaces) + ("http://[fe80::1%25Ethernet%203]:8080/", True), # zone ID "Ethernet 3" + ("http://[fe80::1%25eth%200]:8080/", True), # zone ID "eth 0" + # Numeric zone IDs encoded via %25 (RFC 6874) - regression for %2550 handling + ( + "http://[fe80::1%2550]:8080/", + True, + ), # zone ID "50" (numeric), %2550 = %25 + 50 + # Raw numeric zone IDs (Linux zone indices) + ("http://[fe80::1%1]:8080/", True), # single-digit zone index + ("http://[fe80::1%3]:8080/", True), # single-digit zone index + ("http://[fe80::1%9]:8080/", True), # single-digit zone index + ], + ) + def test_has_ipv6_zone_id(self, url: str, has_zone_id: bool) -> None: + """Test detection of IPv6 zone identifiers in URLs.""" + assert _has_ipv6_zone_id(url) == has_zone_id + + +class TestIPv6ZoneIDParsing: + """Test that IPv6 addresses with zone identifiers are parsed correctly.""" + + @pytest.mark.parametrize( + ("url", "expected_host", "expected_port", "expected_scheme"), + [ + # IPv6 with zone identifiers + ( + "http://[fe80::1%eth0]:8080/path", + "fe80::1%eth0", + 8080, + "http", + ), + ( + "http://[fe80::5054:ff:fe5a:fc0%enp1s0]:80/", + "fe80::5054:ff:fe5a:fc0%enp1s0", + 80, + "http", + ), + ( + "http://[fe80::1%25eth0]:8080/", + "fe80::1%eth0", # %25 is decoded to % by parse_url + 8080, + "http", + ), + ( + "https://[fe80::1%lo]/", + "fe80::1%lo", + None, + "https", + ), + # Regular IPv6 (no zone ID) - should still work + ( + "http://[fe80::1]:8080/", + "fe80::1", + 8080, + "http", + ), + ( + "http://[1200:0000:ab00:1234:0000:2552:7777:1313]:12345/", + "1200:0000:ab00:1234:0000:2552:7777:1313", + 12345, + "http", + ), + ( + "http://[::1]/", + "::1", + None, + "http", + ), + # Regular hostnames and IPv4 + ( + "http://example.com:8080/", + "example.com", + 8080, + "http", + ), + ( + "http://192.168.1.1:9000/", + "192.168.1.1", + 9000, + "http", + ), + ( + "https://google.com/", + "google.com", + None, + "https", + ), + # Edge cases: %25 encoding variations + ( + "http://[fe80::1%25lo]:9090/", + "fe80::1%lo", # %25 decoded to % + 9090, + "http", + ), + ( + "https://[2001:db8::1%25wlan0]:443/path", + "2001:db8::1%wlan0", + 443, + "https", + ), + # Edge cases: Zone ID with path encoding + ( + "http://[fe80::1%eth0]/path%20test", + "fe80::1%eth0", + None, + "http", + ), + ( + "http://[fe80::1%25eth0]/path%20test", + "fe80::1%eth0", # %25 in zone ID decoded, %20 in path preserved + None, + "http", + ), + # Numeric zone ID via %2550 (regression: models.py must re-encode %50 -> %2550 + # so Python 3.14's urlparse does not decode %50 to 'P' and reject the address) + ( + "http://[fe80::1%2550]:8080/", + "fe80::1%50", # %25 decoded to % by parse_url, zone name is "50" + 8080, + "http", + ), + ], + ) + def test_ipv6_zone_id_url_parsing( + self, + url: str, + expected_host: str, + expected_port: "int | None", + expected_scheme: str, + ) -> None: + """Test that URLs with IPv6 zone IDs are parsed correctly.""" + adapter = requests.adapters.HTTPAdapter() + prepared_request = requests.Request("GET", url).prepare() + + # Test that build_connection_pool_key_attributes works correctly + host_params, pool_kwargs = adapter.build_connection_pool_key_attributes( + prepared_request, verify=True + ) + + assert host_params["scheme"] == expected_scheme + assert host_params["host"] == expected_host + assert host_params["port"] == expected_port + # Verify SSL context is set up correctly + assert "cert_reqs" in pool_kwargs + + +class TestIPv6ZoneIDRequests: + """Integration tests for making requests to IPv6 addresses with zone IDs.""" + + def test_ipv6_zone_id_connection_pool_key(self) -> None: + """Test that connection pool keys are properly generated for IPv6 zone IDs.""" + adapter = requests.adapters.HTTPAdapter() + + # Test with IPv6 zone ID + req1 = requests.Request("GET", "http://[fe80::1%eth0]:8080/").prepare() + host_params1, _ = adapter.build_connection_pool_key_attributes( + req1, verify=False + ) + + # Test with different zone ID (should be different pool key) + req2 = requests.Request("GET", "http://[fe80::1%eth1]:8080/").prepare() + host_params2, _ = adapter.build_connection_pool_key_attributes( + req2, verify=False + ) + + # Should have different hosts due to different zone IDs + assert host_params1["host"] != host_params2["host"] + assert host_params1["host"] == "fe80::1%eth0" + assert host_params2["host"] == "fe80::1%eth1" + + def test_ipv6_zone_id_with_client_cert(self) -> None: + """Test that client certificates work with IPv6 zone IDs.""" + adapter = requests.adapters.HTTPAdapter() + req = requests.Request("GET", "https://[fe80::1%eth0]:8443/").prepare() + + cert = ("/path/to/client.pem", "/path/to/client.key") + host_params, pool_kwargs = adapter.build_connection_pool_key_attributes( + req, verify=False, cert=cert + ) + + assert host_params["host"] == "fe80::1%eth0" + assert host_params["scheme"] == "https" + assert "cert_reqs" in pool_kwargs + assert pool_kwargs["cert_file"] == "/path/to/client.pem" + assert pool_kwargs["key_file"] == "/path/to/client.key" + + def test_ipv6_zone_id_full_request_flow_with_mocking( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Integration test: Full request flow from PreparedRequest to connection pool.""" + # Create adapter and prepare request with %25-encoded zone ID + adapter = requests.adapters.HTTPAdapter() + url = "http://[fe80::1%25eth0]:8080/api/test" + req = requests.Request("GET", url).prepare() + + # Verify URL is properly canonicalized after prepare + assert "%25" in req.url # Should maintain %25 encoding + + # Mock the connection pool's urlopen method + mock_response = HTTPResponse( + body=b"test response", + headers={"Content-Type": "application/json"}, + status=200, + preload_content=False, + ) + + # Mock connection_from_host to capture the host parameter + captured_host = None + + def mock_connection_from_host(*args: object, **kwargs: object) -> MagicMock: + nonlocal captured_host + # Capture the host from kwargs if present, otherwise from args + if "host" in kwargs: + captured_host = kwargs["host"] + mock_conn = MagicMock() + mock_conn.urlopen.return_value = mock_response + return mock_conn + + monkeypatch.setattr( + adapter.poolmanager, + "connection_from_host", + mock_connection_from_host, + ) + + response = adapter.send(req, verify=False) + assert response.status_code == 200 + assert captured_host == "fe80::1%eth0" + + def test_ipv6_zone_id_different_encodings_create_correct_pools(self) -> None: + """Test that %25 and raw % encodings both produce the same pool key.""" + adapter = requests.adapters.HTTPAdapter() + + # RFC 6874 compliant %25-encoded zone ID + req1 = requests.Request("GET", "http://[fe80::1%25eth0]:8080/").prepare() + host_params1, _ = adapter.build_connection_pool_key_attributes( + req1, verify=False + ) + + # Raw (literal) % zone ID form + req2 = requests.Request("GET", "http://[fe80::1%eth0]:8080/").prepare() + host_params2, _ = adapter.build_connection_pool_key_attributes( + req2, verify=False + ) + + # Both encodings must resolve to the same internal host representation + assert host_params1["host"] == "fe80::1%eth0" + assert host_params1["port"] == 8080 + assert host_params1["scheme"] == "http" + assert host_params2["host"] == host_params1["host"] + assert host_params2["port"] == host_params1["port"] + assert host_params2["scheme"] == host_params1["scheme"] + + def test_ipv6_zone_id_preserved_through_url_preparation(self) -> None: + """Test that zone IDs are preserved through the entire URL preparation flow.""" + # Start with a URL that has %25-encoded zone ID + original_url = "http://[fe80::1%25eth0]:8080/path?query=value" + + # Prepare the request (this goes through prepare_url in models.py) + req = requests.Request("GET", original_url).prepare() + + # The prepared URL should maintain %25 encoding (canonical form) + assert "%25" in req.url + assert "fe80::1" in req.url + assert "eth0" in req.url + + # Now test that adapter can parse this correctly + adapter = requests.adapters.HTTPAdapter() + host_params, _ = adapter.build_connection_pool_key_attributes(req, verify=False) + + # The host should have single % (decoded for connection) + assert host_params["host"] == "fe80::1%eth0" + + def test_ipv6_zone_id_with_percent_encoded_name(self) -> None: + """Zone IDs whose names contain %XX-encoded characters (e.g. spaces) behave + differently depending on the Python version. + + Python 3.14 added _check_bracketed_netloc to urlparse, which calls + ipaddress.ip_address() on the bracketed host. Python's ipaddress splits + on the first literal % to extract the scope ID, so a URL like + http://[fe80::1%25Ethernet%203]:8080/ yields scope "25Ethernet%203" which + contains a bare % and is rejected. + + On Python < 3.14 urlparse does not perform this validation, so the full + pipeline (prepare -> adapter parsing) works correctly. + """ + original_url = "http://[fe80::1%25Ethernet%203]:8080/path" + + if sys.version_info >= (3, 14): + # urlparse now validates IPv6 scope IDs; % inside the scope is rejected + with pytest.raises((ValueError, requests.exceptions.InvalidURL)): + requests.Request("GET", original_url).prepare() + else: + req = requests.Request("GET", original_url).prepare() + + # models.py re-encodes the zone delimiter so %25 is preserved + assert "%25" in req.url + assert "Ethernet" in req.url + + adapter = requests.adapters.HTTPAdapter() + host_params, _ = adapter.build_connection_pool_key_attributes( + req, verify=False + ) + + # parse_url decodes %25 -> % when returning the host for the connection + assert host_params["host"] == "fe80::1%Ethernet%203" + assert host_params["port"] == 8080 + assert host_params["scheme"] == "http" + + @pytest.mark.parametrize( + ("url", "expected_path"), + [ + ("http://[fe80::1%25eth0]:8080/api/test", "/api/test"), + ("http://[fe80::1%25eth0]:8080/", "/"), + ("http://[fe80::1%25eth0]/path?q=1", "/path?q=1"), + ("http://[fe80::1%25lo]:9090/a/b/c", "/a/b/c"), + ], + ) + def test_ipv6_zone_id_request_url(self, url: str, expected_path: str) -> None: + """Test that request_url() extracts the correct path for zone ID URLs.""" + adapter = requests.adapters.HTTPAdapter() + req = requests.Request("GET", url).prepare() + assert adapter.request_url(req, {}) == expected_path + + def test_ipv6_zone_id_proxy_connection( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that zone ID URLs work through the proxy path in + get_connection_with_tls_context.""" + adapter = requests.adapters.HTTPAdapter() + url = "http://[fe80::1%25eth0]:8080/api/test" + req = requests.Request("GET", url).prepare() + + captured_host_params: dict = {} + + def mock_proxy_connection_from_host( + *args: object, **kwargs: object + ) -> MagicMock: + captured_host_params["host"] = kwargs.get("host") + captured_host_params["scheme"] = kwargs.get("scheme") + captured_host_params["port"] = kwargs.get("port") + mock_conn = MagicMock() + return mock_conn + + mock_proxy_manager = MagicMock() + mock_proxy_manager.connection_from_host = mock_proxy_connection_from_host + monkeypatch.setattr( + adapter, "proxy_manager_for", lambda proxy, **kw: mock_proxy_manager + ) + + adapter.get_connection_with_tls_context( + req, verify=False, proxies={"http": "http://proxy.example.com:3128"} + ) + + assert captured_host_params["host"] == "fe80::1%eth0" + assert captured_host_params["scheme"] == "http" + assert captured_host_params["port"] == 8080