From a4ccd7bcccf62b75f8b3df902e1e4d163cc8ad13 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:53:24 -0700 Subject: [PATCH 1/7] Respect no_proxy environment variable An explicitly configured proxy was used for every request, even when the target host matched the no_proxy / NO_PROXY environment variable. Add a _proxies_for_url helper that returns an empty proxy mapping for bypassed hosts so libcloud behaves consistently with other HTTP clients. --- CHANGES.rst | 5 +++++ libcloud/http.py | 24 ++++++++++++++++++++++++ libcloud/test/test_connection.py | 12 ++++++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 88b983e4bf..b8fc14522f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,11 @@ Changes in Apache Libcloud 3.9.2 Common ~~~~~~ +- Respect the ``no_proxy`` / ``NO_PROXY`` environment variable so an explicitly + configured proxy is bypassed for matching hosts. + (GITHUB-2077) + [Sanjay Santhanam - @Sanjays2402] + - Move tests to python 3.12. (#2152) [Miguel Caballer - @micafer] diff --git a/libcloud/http.py b/libcloud/http.py index 4c1c1b01e4..21c0a0d4a8 100644 --- a/libcloud/http.py +++ b/libcloud/http.py @@ -22,6 +22,7 @@ import requests from requests.adapters import HTTPAdapter +from requests.utils import should_bypass_proxies import libcloud.security from libcloud.utils.py3 import urlparse @@ -112,6 +113,28 @@ def set_http_proxy(self, proxy_url): "https": proxy_url, } + + def _proxies_for_url(self, url): + """ + Return the proxy mapping to use for ``url``. + + An explicitly configured proxy is skipped when the target host matches + the ``no_proxy`` / ``NO_PROXY`` environment variable, so libcloud + behaves consistently with other HTTP clients. + + :param url: Absolute request URL. + :type url: ``str`` + + :rtype: ``dict`` or ``None`` + """ + if not self.session.proxies: + return None + + if should_bypass_proxies(url, no_proxy=None): + return {} + + return None + def _parse_proxy_url(self, proxy_url): """ Parse and validate a proxy URL. @@ -231,6 +254,7 @@ def request(self, method, url, body=None, headers=None, raw=False, stream=False, verify=self.verification, timeout=self.session.timeout, hooks=hooks, + proxies=self._proxies_for_url(url), ) def prepared_request(self, method, url, body=None, headers=None, raw=False, stream=False): diff --git a/libcloud/test/test_connection.py b/libcloud/test/test_connection.py index a7bf1d7c8e..416cee401f 100644 --- a/libcloud/test/test_connection.py +++ b/libcloud/test/test_connection.py @@ -161,6 +161,18 @@ def test_constructor(self): {"http": "https://127.0.0.6:3129", "https": "https://127.0.0.6:3129"}, ) + def test_proxy_is_bypassed_for_no_proxy_hosts(self): + # Regression test for GITHUB-2077: an explicitly configured proxy must + # not be used for hosts listed in the no_proxy environment variable. + os.environ["no_proxy"] = "internal.example.com" + self.addCleanup(os.environ.pop, "no_proxy", None) + + conn = LibcloudConnection(host="internal.example.com", port=443) + conn.set_http_proxy("http://proxy.example.com:3128") + + self.assertEqual(conn._proxies_for_url("https://internal.example.com/path"), {}) + self.assertIsNone(conn._proxies_for_url("https://other.example.com/path")) + def test_proxy_environment_variables_respected(self): """ Test that proxy environment variables are respected by the underlying Requests library From 5a8e247c4c6ec496a9dc94b9d913235cc7a38909 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam Date: Tue, 8 Sep 2026 23:35:41 -0700 Subject: [PATCH 2/7] Address review feedback from @micafer: restore (not drop) proxy env vars in test cleanup and pin NO_PROXY --- libcloud/test/test_connection.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/libcloud/test/test_connection.py b/libcloud/test/test_connection.py index 416cee401f..a7be437fa6 100644 --- a/libcloud/test/test_connection.py +++ b/libcloud/test/test_connection.py @@ -164,8 +164,25 @@ def test_constructor(self): def test_proxy_is_bypassed_for_no_proxy_hosts(self): # Regression test for GITHUB-2077: an explicitly configured proxy must # not be used for hosts listed in the no_proxy environment variable. + old_no_proxy = os.environ.get("no_proxy") + old_NO_PROXY = os.environ.get("NO_PROXY") os.environ["no_proxy"] = "internal.example.com" - self.addCleanup(os.environ.pop, "no_proxy", None) + # Pin NO_PROXY too: an externally-set uppercase variable must not leak + # into this test, and our addCleanup must restore (not drop) whatever + # was there before, since it runs after tearDown(). + os.environ.pop("NO_PROXY", None) + + def restore_proxy_env(): + for name, value in ( + ("no_proxy", old_no_proxy), + ("NO_PROXY", old_NO_PROXY), + ): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + self.addCleanup(restore_proxy_env) conn = LibcloudConnection(host="internal.example.com", port=443) conn.set_http_proxy("http://proxy.example.com:3128") From 34fd511cb0600ce6c1f9710f02b849518510a05d Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam Date: Wed, 9 Sep 2026 21:09:51 -0700 Subject: [PATCH 3/7] fix: explicitly disable proxy schemes instead of returning {} Returning {} from _proxies_for_url() was not sufficient: requests merges per-request proxies with the session proxies (merge_setting), so the session-level http/https proxy was merged back in and still used for no_proxy hosts. Now returns {'http': None, 'https': None}; requests strips None values during the merge, leaving an empty effective mapping. Also adds an end-to-end-ish test around LibcloudConnection.request() (with HTTPAdapter.send mocked) asserting the proxy mapping that actually reaches the adapter after requests processing, plus a control case showing non-bypassed hosts still use the configured proxy. --- libcloud/http.py | 7 ++- libcloud/test/test_connection.py | 85 +++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/libcloud/http.py b/libcloud/http.py index 21c0a0d4a8..467b787db9 100644 --- a/libcloud/http.py +++ b/libcloud/http.py @@ -131,7 +131,12 @@ def _proxies_for_url(self, url): return None if should_bypass_proxies(url, no_proxy=None): - return {} + # Explicitly disable the configured schemes. Returning {} is not + # enough: requests merges per-request proxies with the session + # proxies, so the session-level proxy would be merged back in. + # ``None`` values are stripped by requests' merge, leaving an + # empty effective mapping. + return {"http": None, "https": None} return None diff --git a/libcloud/test/test_connection.py b/libcloud/test/test_connection.py index a7be437fa6..a68c9ed7ed 100644 --- a/libcloud/test/test_connection.py +++ b/libcloud/test/test_connection.py @@ -21,7 +21,7 @@ from unittest.mock import Mock, patch import requests_mock -from requests.adapters import HTTPAdapter +from requests.adapters import HTTPAdapter, select_proxy from requests.exceptions import ConnectTimeout import libcloud.common.base @@ -187,9 +187,90 @@ def restore_proxy_env(): conn = LibcloudConnection(host="internal.example.com", port=443) conn.set_http_proxy("http://proxy.example.com:3128") - self.assertEqual(conn._proxies_for_url("https://internal.example.com/path"), {}) + self.assertEqual( + conn._proxies_for_url("https://internal.example.com/path"), + {"http": None, "https": None}, + ) self.assertIsNone(conn._proxies_for_url("https://other.example.com/path")) + def test_request_effective_proxies_bypass_session_proxy_for_no_proxy_host(self): + # End-to-end-ish check for GITHUB-2077: verify the proxy mapping that + # actually reaches the HTTP adapter after requests merges the + # per-request proxies with the session proxies. Returning {} from + # _proxies_for_url() is not sufficient because requests merges the + # session-level proxies back in, so the schemes must be explicitly + # disabled with None. + old_no_proxy = os.environ.get("no_proxy") + old_NO_PROXY = os.environ.get("NO_PROXY") + os.environ["no_proxy"] = "internal.example.com" + os.environ.pop("NO_PROXY", None) + # Pin the proxy environment too: ambient http_proxy/https_proxy would + # otherwise be merged in by requests (trust_env) and break the + # control-case assertions below. + old_proxy_env = {} + for name in ( + "http_proxy", + "https_proxy", + "all_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + ): + old_proxy_env[name] = os.environ.pop(name, None) + + def restore_proxy_env(): + for name, value in ( + ("no_proxy", old_no_proxy), + ("NO_PROXY", old_NO_PROXY), + ): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + for name, value in old_proxy_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + self.addCleanup(restore_proxy_env) + + captured = {} + + def mock_send(self, request, **kwargs): + captured["proxies"] = kwargs.get("proxies", {}) + captured["url"] = request.url + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json", "location": ""} + mock_response.text = "OK" + mock_response.history = [] # No redirects + return mock_response + + with patch.object(HTTPAdapter, "send", mock_send): + conn = LibcloudConnection(host="internal.example.com", port=443) + conn.set_http_proxy("http://proxy.example.com:3128") + conn.request("GET", "/path") + + # The session proxy must not leak back in via requests' merge. + self.assertNotIn( + "http://proxy.example.com:3128", captured["proxies"].values() + ) + self.assertIsNone(select_proxy(captured["url"], captured["proxies"])) + + # Control: a host that is not bypassed still uses the proxy. + conn = LibcloudConnection(host="other.example.com", port=443) + conn.set_http_proxy("http://proxy.example.com:3128") + conn.request("GET", "/path") + + self.assertEqual( + captured["proxies"].get("http"), "http://proxy.example.com:3128" + ) + self.assertEqual( + select_proxy(captured["url"], captured["proxies"]), + "http://proxy.example.com:3128", + ) + def test_proxy_environment_variables_respected(self): """ Test that proxy environment variables are respected by the underlying Requests library From 47df9a42ff44ad92a53ac0710eb71c61f2e2ca9b Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam Date: Thu, 10 Sep 2026 00:32:48 -0700 Subject: [PATCH 4/7] style: apply black formatting to http.py and test_connection.py --- libcloud/http.py | 1 - libcloud/test/test_connection.py | 8 ++------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/libcloud/http.py b/libcloud/http.py index 467b787db9..f398faf7ad 100644 --- a/libcloud/http.py +++ b/libcloud/http.py @@ -113,7 +113,6 @@ def set_http_proxy(self, proxy_url): "https": proxy_url, } - def _proxies_for_url(self, url): """ Return the proxy mapping to use for ``url``. diff --git a/libcloud/test/test_connection.py b/libcloud/test/test_connection.py index a68c9ed7ed..d23fa7a829 100644 --- a/libcloud/test/test_connection.py +++ b/libcloud/test/test_connection.py @@ -253,9 +253,7 @@ def mock_send(self, request, **kwargs): conn.request("GET", "/path") # The session proxy must not leak back in via requests' merge. - self.assertNotIn( - "http://proxy.example.com:3128", captured["proxies"].values() - ) + self.assertNotIn("http://proxy.example.com:3128", captured["proxies"].values()) self.assertIsNone(select_proxy(captured["url"], captured["proxies"])) # Control: a host that is not bypassed still uses the proxy. @@ -263,9 +261,7 @@ def mock_send(self, request, **kwargs): conn.set_http_proxy("http://proxy.example.com:3128") conn.request("GET", "/path") - self.assertEqual( - captured["proxies"].get("http"), "http://proxy.example.com:3128" - ) + self.assertEqual(captured["proxies"].get("http"), "http://proxy.example.com:3128") self.assertEqual( select_proxy(captured["url"], captured["proxies"]), "http://proxy.example.com:3128", From 2d332bda2a4bc8a14891bda31faad5eb51313b7c Mon Sep 17 00:00:00 2001 From: Miguel Caballer Fernandez Date: Thu, 10 Sep 2026 10:21:42 +0200 Subject: [PATCH 5/7] Reorganize imports in http.py --- libcloud/http.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libcloud/http.py b/libcloud/http.py index f398faf7ad..02eb97c2c5 100644 --- a/libcloud/http.py +++ b/libcloud/http.py @@ -24,9 +24,6 @@ from requests.adapters import HTTPAdapter from requests.utils import should_bypass_proxies -import libcloud.security -from libcloud.utils.py3 import urlparse - try: # requests no longer vendors urllib3 in newer versions # https://github.com/python/typeshed/issues/6893#issuecomment-1012511758 @@ -34,6 +31,9 @@ except ImportError: from requests.packages.urllib3.poolmanager import PoolManager # type: ignore +import libcloud.security +from libcloud.utils.py3 import urlparse + __all__ = ["LibcloudBaseConnection", "LibcloudConnection"] From b2cb332f653347cf057b205b068a37a5670b4930 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Fernandez Date: Thu, 10 Sep 2026 11:33:13 +0200 Subject: [PATCH 6/7] Reorder import statements in http.py --- libcloud/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libcloud/http.py b/libcloud/http.py index 02eb97c2c5..f087482461 100644 --- a/libcloud/http.py +++ b/libcloud/http.py @@ -21,8 +21,8 @@ import warnings import requests -from requests.adapters import HTTPAdapter from requests.utils import should_bypass_proxies +from requests.adapters import HTTPAdapter try: # requests no longer vendors urllib3 in newer versions From 3c6135a9a28bdb3116b281f9a3132e1f4f477d7f Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Thu, 10 Sep 2026 12:43:47 +0200 Subject: [PATCH 7/7] Fix style --- libcloud/http.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libcloud/http.py b/libcloud/http.py index f087482461..f6dc59fd0c 100644 --- a/libcloud/http.py +++ b/libcloud/http.py @@ -34,7 +34,6 @@ import libcloud.security from libcloud.utils.py3 import urlparse - __all__ = ["LibcloudBaseConnection", "LibcloudConnection"] ALLOW_REDIRECTS = 1