From d8025e3d0f7576df227eda665ca58891474aa4e8 Mon Sep 17 00:00:00 2001 From: mdali9821 Date: Fri, 24 Apr 2026 11:01:00 +0530 Subject: [PATCH] Preserve IPv6 zone identifier in prepared URLs requote_uri percent-encodes the literal '%' zone-id separator to '%25', which urllib3 then forwards verbatim to getaddrinfo(). The kernel cannot resolve a zone literally named '%25eth0', so link-local IPv6 URLs such as https://[fe80::1%ens192]/ fail with [Errno -2] Name or service not known since requests 2.32.0. Restore the literal '%' inside the bracketed host after requoting. urllib3.util.parse_url already normalizes both the literal ('%eth0') and RFC 6874 ('%25eth0') forms to the literal form, so we only need to undo the re-encoding that requote_uri performs on the authority. Fixes #6735. --- src/requests/models.py | 10 ++++++++++ tests/test_requests.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/requests/models.py b/src/requests/models.py index e158b6cca2..08dcdb4455 100644 --- a/src/requests/models.py +++ b/src/requests/models.py @@ -480,6 +480,16 @@ def prepare_url(self, url, params): query = enc_params url = requote_uri(urlunparse([scheme, netloc, path, None, query, fragment])) + + # Preserve the IPv6 zone identifier separator ('%') inside a bracketed + # host. ``requote_uri`` percent-encodes a literal '%' to '%25', but + # ``urllib3`` forwards the (already-parsed) host to ``getaddrinfo()`` + # verbatim, so an encoded zone identifier like ``%25eth0`` cannot be + # resolved by the OS. See https://github.com/psf/requests/issues/6735. + if host and "%" in host: + encoded_host = host.replace("%", "%25", 1) + url = url.replace(encoded_host, host, 1) + self.url = url def prepare_headers(self, headers): diff --git a/tests/test_requests.py b/tests/test_requests.py index 6d1bef66e0..a0ad23b464 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -147,6 +147,39 @@ def test_path_is_not_double_encoded(self): assert request.path_url == "/get/test%20case" + @pytest.mark.parametrize( + "url, expected", + ( + ( + # Literal '%' zone-id separator must be preserved, otherwise + # urllib3 will forward '%25ens192' to getaddrinfo() which the + # OS cannot resolve. Regression test for #6735. + "https://[fe80::1%ens192]/redfish/v1", + "https://[fe80::1%ens192]/redfish/v1", + ), + ( + # RFC 6874 form ('%25') must be normalized back to the literal + # form expected by getaddrinfo() / urllib3. + "https://[fe80::1%25ens192]/redfish/v1", + "https://[fe80::1%ens192]/redfish/v1", + ), + ( + # Zone-id preserved alongside port/query/fragment. + "https://[fe80::5eed:8cff:fe00:0da4%ens192]:8443/api?x=1#f", + "https://[fe80::5eed:8cff:fe00:0da4%ens192]:8443/api?x=1#f", + ), + ( + # Plain IPv6 (no zone id) must be unaffected. + "https://[::1]/", + "https://[::1]/", + ), + ), + ) + def test_ipv6_zone_id_is_preserved(self, url, expected): + """See: https://github.com/psf/requests/issues/6735""" + request = requests.Request("GET", url).prepare() + assert request.url == expected + @pytest.mark.parametrize( "url, expected", (