Skip to content

Commit d0913ee

Browse files
authored
feat: NO_PROXY environment variable can be used to override HTTP(S)_PROXY values (#301)
When determining if a proxy should be used, the SDK would: 1. Check the `config.http_config.http_proxy` value. If that is set, use that value without further consideration. 2. If the target URI is `https`, use the value from the `HTTPS_PROXY` environment variable. 3. If the target is `http`, use `HTTP_PROXY` instead. The SDK will now support another environment variable -- `NO_PROXY`. This variable can be set to a comma-separated list of hosts to exclude from proxy support, or the special case '*' meaning to ignore all hosts. The `NO_PROXY` variable will only take affect if the SDK isn't explicitly configured to use a proxy as specified in #1 above.
1 parent 3cc6e35 commit d0913ee

File tree

3 files changed

+132
-17
lines changed

3 files changed

+132
-17
lines changed

ldclient/impl/http.py

+63-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import certifi
33
from os import environ
44
import urllib3
5+
from urllib.parse import urlparse
6+
from typing import Tuple
7+
58

69
def _application_header_value(application: dict) -> str:
710
parts = []
@@ -34,9 +37,11 @@ def _base_headers(config):
3437

3538
return headers
3639

40+
3741
def _http_factory(config):
3842
return HTTPFactory(_base_headers(config), config.http)
3943

44+
4045
class HTTPFactory:
4146
def __init__(self, base_headers, http_config, override_read_timeout=None):
4247
self.__base_headers = base_headers
@@ -73,26 +78,77 @@ def create_pool_manager(self, num_pools, target_base_uri):
7378
num_pools=num_pools,
7479
cert_reqs=cert_reqs,
7580
ca_certs=ca_certs
76-
)
81+
)
7782
else:
7883
# Get proxy authentication, if provided
7984
url = urllib3.util.parse_url(proxy_url)
8085
proxy_headers = None
81-
if url.auth != None:
86+
if url.auth is not None:
8287
proxy_headers = urllib3.util.make_headers(proxy_basic_auth=url.auth)
8388
# Create a proxied connection
8489
return urllib3.ProxyManager(
8590
proxy_url,
8691
num_pools=num_pools,
8792
cert_reqs=cert_reqs,
88-
ca_certs = ca_certs,
93+
ca_certs=ca_certs,
8994
proxy_headers=proxy_headers
9095
)
9196

97+
9298
def _get_proxy_url(target_base_uri):
99+
"""
100+
Determine the proxy URL to use for a given target URI, based on the
101+
environment variables http_proxy, https_proxy, and no_proxy.
102+
103+
If the target URI is an https URL, the proxy will be determined from the HTTPS_PROXY variable.
104+
If the target URI is not https, the proxy will be determined from the HTTP_PROXY variable.
105+
106+
In either of the above instances, if the NO_PROXY variable contains either
107+
the target domain or '*', no proxy will be used.
108+
"""
93109
if target_base_uri is None:
94110
return None
95-
is_https = target_base_uri.startswith('https:')
96-
if is_https:
97-
return environ.get('https_proxy')
98-
return environ.get('http_proxy')
111+
112+
target_host, target_port, is_https = _get_target_host_and_port(target_base_uri)
113+
114+
proxy_url = environ.get('https_proxy') if is_https else environ.get('http_proxy')
115+
no_proxy = environ.get('no_proxy', '').strip()
116+
117+
if proxy_url is None or no_proxy == '*':
118+
return None
119+
elif no_proxy == '':
120+
return proxy_url
121+
122+
for no_proxy_entry in no_proxy.split(','):
123+
parts = no_proxy_entry.strip().split(':')
124+
if len(parts) == 1:
125+
if target_host.endswith(no_proxy_entry):
126+
return None
127+
continue
128+
129+
if target_host.endswith(parts[0]) and target_port == int(parts[1]):
130+
return None
131+
132+
return proxy_url
133+
134+
135+
def _get_target_host_and_port(uri: str) -> Tuple[str, int, bool]:
136+
"""
137+
Given a URL, return the effective hostname, port, and whether it is considered a secure scheme.
138+
139+
If a scheme is not supplied, the port is assumed to be 80 and the connection unsecure.
140+
If a scheme and port is provided, the port will be parsed from the URI.
141+
If only a scheme is provided, the port will be 443 if the scheme is 'https', otherwise 80.
142+
"""
143+
if '//' not in uri:
144+
parts = uri.split(':')
145+
return (parts[0], int(parts[1]) if len(parts) > 1 else 80, False)
146+
147+
parsed = urlparse(uri)
148+
is_https = parsed.scheme == 'https'
149+
150+
port = parsed.port
151+
if port is None:
152+
port = 443 if is_https else 80
153+
154+
return parsed.hostname or "", port, is_https

ldclient/testing/impl/test_http.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import pytest
2+
3+
from typing import Optional
4+
from ldclient.impl.http import _get_proxy_url
5+
6+
7+
@pytest.mark.parametrize(
8+
'target_uri, no_proxy, expected',
9+
[
10+
('https://secure.example.com', '', 'https://secure.proxy:1234'),
11+
('http://insecure.example.com', '', 'http://insecure.proxy:6789'),
12+
13+
('https://secure.example.com', 'secure.example.com', None),
14+
('https://secure.example.com', 'secure.example.com:443', None),
15+
('https://secure.example.com', 'secure.example.com:80', 'https://secure.proxy:1234'),
16+
('https://secure.example.com', 'wrong.example.com', 'https://secure.proxy:1234'),
17+
('https://secure.example.com:8080', 'secure.example.com', None),
18+
('https://secure.example.com:8080', 'secure.example.com:443', 'https://secure.proxy:1234'),
19+
20+
('https://secure.example.com', 'example.com', None),
21+
('https://secure.example.com', 'example.com:443', None),
22+
('https://secure.example.com', 'example.com:80', 'https://secure.proxy:1234'),
23+
24+
('http://insecure.example.com', 'insecure.example.com', None),
25+
('http://insecure.example.com', 'insecure.example.com:443', 'http://insecure.proxy:6789'),
26+
('http://insecure.example.com', 'insecure.example.com:80', None),
27+
('http://insecure.example.com', 'wrong.example.com', 'http://insecure.proxy:6789'),
28+
('http://insecure.example.com:8080', 'secure.example.com', None),
29+
('http://insecure.example.com:8080', 'secure.example.com:443', 'http://insecure.proxy:6789'),
30+
31+
('http://insecure.example.com', 'example.com', None),
32+
('http://insecure.example.com', 'example.com:443', 'http://insecure.proxy:6789'),
33+
('http://insecure.example.com', 'example.com:80', None),
34+
35+
('secure.example.com', 'secure.example.com', None),
36+
('secure.example.com', 'secure.example.com:443', 'http://insecure.proxy:6789'),
37+
('secure.example.com', 'secure.example.com:80', None),
38+
('secure.example.com', 'wrong.example.com', 'http://insecure.proxy:6789'),
39+
('secure.example.com:8080', 'secure.example.com', None),
40+
('secure.example.com:8080', 'secure.example.com:80', 'http://insecure.proxy:6789'),
41+
42+
('https://secure.example.com', '*', None),
43+
('https://secure.example.com:8080', '*', None),
44+
('http://insecure.example.com', '*', None),
45+
('http://insecure.example.com:8080', '*', None),
46+
('secure.example.com:443', '*', None),
47+
('insecure.example.com:8080', '*', None),
48+
]
49+
)
50+
def test_honors_no_proxy(target_uri: str, no_proxy: str, expected: Optional[str], monkeypatch):
51+
monkeypatch.setenv('https_proxy', 'https://secure.proxy:1234')
52+
monkeypatch.setenv('http_proxy', 'http://insecure.proxy:6789')
53+
monkeypatch.setenv('no_proxy', no_proxy)
54+
55+
proxy_url = _get_proxy_url(target_uri)
56+
57+
assert proxy_url == expected

ldclient/testing/proxy_test_util.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ldclient.config import Config, HTTPConfig
2-
from ldclient.testing.http_util import start_server, BasicResponse, JsonResponse
2+
from ldclient.testing.http_util import start_server
3+
34

45
# Runs tests of all of our supported proxy server configurations: secure or insecure, configured
56
# by Config.http_proxy or by an environment variable, with or without authentication. The action
@@ -16,7 +17,8 @@ def do_proxy_tests(action, action_method, monkeypatch):
1617
(False, True, False),
1718
(True, False, False),
1819
(True, False, True),
19-
(True, True, False)]:
20+
(True, True, False)
21+
]:
2022
test_desc = "%s, %s, %s" % (
2123
"using env vars" if use_env_vars else "using Config",
2224
"secure" if secure else "insecure",
@@ -27,23 +29,23 @@ def do_proxy_tests(action, action_method, monkeypatch):
2729
if use_env_vars:
2830
monkeypatch.setenv('https_proxy' if secure else 'http_proxy', proxy_uri)
2931
config = Config(
30-
sdk_key = 'sdk_key',
31-
base_uri = target_uri,
32-
events_uri = target_uri,
33-
stream_uri = target_uri,
34-
http = HTTPConfig(http_proxy=proxy_uri),
35-
diagnostic_opt_out = True)
32+
sdk_key='sdk_key',
33+
base_uri=target_uri,
34+
events_uri=target_uri,
35+
stream_uri=target_uri,
36+
http=HTTPConfig(http_proxy=proxy_uri),
37+
diagnostic_opt_out=True)
3638
try:
3739
action(server, config, secure)
38-
except:
40+
except Exception:
3941
print("test action failed (%s)" % test_desc)
4042
raise
4143
# For an insecure proxy request, our stub server behaves enough like the real thing to satisfy the
4244
# HTTP client, so we should be able to see the request go through. Note that the URI path will
4345
# actually be an absolute URI for a proxy request.
4446
try:
4547
req = server.require_request()
46-
except:
48+
except Exception:
4749
print("server did not receive a request (%s)" % test_desc)
4850
raise
4951
expected_method = 'CONNECT' if secure else action_method

0 commit comments

Comments
 (0)