Skip to content

Commit 7b55e5f

Browse files
committed
[SPT-1668] Skip CA bundle env vars that don't point to a file
If SSL_CERT_FILE or REQUESTS_CA_BUNDLE is set to a path that doesn't exist, log a warning and fall back to the next option instead of failing, so a stale value doesn't break existing setups. An explicit ca_certs argument is still used as given. RESTClient resolves the path once so it warns once for both the main and vx clients.
1 parent 823b1a8 commit 7b55e5f

7 files changed

Lines changed: 72 additions & 18 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ client = RESTClient(ca_certs="/path/to/corporate-ca-bundle.pem")
160160
ws_client = WebSocketClient(ca_certs="/path/to/corporate-ca-bundle.pem")
161161
```
162162

163-
The client uses the first one it finds: `ca_certs`, then `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then the bundled [certifi](https://pypi.org/project/certifi/) certificates.
163+
The client uses the first one it finds: `ca_certs`, then `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then the bundled [certifi](https://pypi.org/project/certifi/) certificates. If an environment variable points to a file that doesn't exist, it's skipped and a warning is logged.
164164

165165
The bundle replaces certifi, so it must also include the public root certificates. If you only have your company's root certificate, add it to certifi's bundle:
166166

‎massive/certs.py‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
1+
import logging
12
import os
3+
from typing import Optional
4+
25
import certifi
36

47

5-
def ca_bundle_path() -> str:
8+
def ca_bundle_path(logger: Optional[logging.Logger] = None) -> str:
69
"""
710
Path of the CA bundle used to verify TLS connections.
811
912
Honors the standard ``SSL_CERT_FILE`` and ``REQUESTS_CA_BUNDLE`` environment
1013
variables (used for example behind corporate TLS inspection), falling back to
11-
the certifi bundle.
14+
the certifi bundle. A variable that doesn't point to an existing file is
15+
skipped with a warning, so a stale value doesn't break connections.
1216
"""
13-
return (
14-
os.environ.get("SSL_CERT_FILE")
15-
or os.environ.get("REQUESTS_CA_BUNDLE")
16-
or certifi.where()
17-
)
17+
for env_var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
18+
path = os.environ.get(env_var)
19+
if not path:
20+
continue
21+
if os.path.isfile(path):
22+
return path
23+
if logger:
24+
logger.warning(
25+
"%s is set to %r, which isn't a file; ignoring it", env_var, path
26+
)
27+
return certifi.where()

‎massive/rest/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
ContractsClient,
2121
)
2222
from .vX import VXClient
23+
from .base import logger
24+
from ..certs import ca_bundle_path
2325
from typing import Optional, Any, Dict
2426
import os
2527

@@ -74,6 +76,8 @@ def __init__(
7476
:param connection_pool_kw: Other urllib3 PoolManager options. They override
7577
the client's defaults.
7678
"""
79+
# resolve once, so the main and vx clients share it and warn only once
80+
ca_certs = ca_certs or ca_bundle_path(logger)
7781
super().__init__(
7882
api_key=api_key,
7983
connect_timeout=connect_timeout,

‎massive/rest/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def __init__(
8383
# clients reconnect (and redo the TLS handshake) for most requests
8484
"maxsize": maxsize,
8585
"headers": self.headers, # default headers sent with each request.
86-
"ca_certs": ca_certs or ca_bundle_path(),
86+
"ca_certs": ca_certs or ca_bundle_path(logger),
8787
"cert_reqs": "CERT_REQUIRED",
8888
"retries": retry_strategy, # use the customized Retry instance
8989
"timeout": self.timeout, # set timeout for each request

‎massive/websocket/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def _ssl_context(self) -> Optional[ssl.SSLContext]:
177177
if not self.url.startswith("wss://"):
178178
return None
179179
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
180-
context.load_verify_locations(self.ca_certs or ca_bundle_path())
180+
context.load_verify_locations(self.ca_certs or ca_bundle_path(logger))
181181
return context
182182

183183
def run(

‎test_rest/test_connection.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import tempfile
23
import unittest
34
from unittest import mock
45

@@ -9,6 +10,14 @@
910

1011

1112
class ConnectionOptionsTest(unittest.TestCase):
13+
def setUp(self):
14+
tmp = tempfile.TemporaryDirectory()
15+
self.addCleanup(tmp.cleanup)
16+
self.ssl_ca = os.path.join(tmp.name, "ssl-ca.pem")
17+
self.requests_ca = os.path.join(tmp.name, "requests-ca.pem")
18+
for path in (self.ssl_ca, self.requests_ca):
19+
open(path, "w").close()
20+
1221
def client(self, env=None, **kwargs):
1322
with mock.patch.dict(os.environ, env or {}, clear=True):
1423
return RESTClient(api_key="key", **kwargs)
@@ -18,15 +27,31 @@ def test_ca_bundle_defaults_to_certifi(self):
1827
self.assertEqual(c.client.connection_pool_kw["ca_certs"], certifi.where())
1928

2029
def test_ca_bundle_from_env(self):
21-
c = self.client({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"})
22-
self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/requests-ca.pem")
30+
c = self.client({"REQUESTS_CA_BUNDLE": self.requests_ca})
31+
self.assertEqual(c.client.connection_pool_kw["ca_certs"], self.requests_ca)
2332
c = self.client(
24-
{"SSL_CERT_FILE": "/ssl-ca.pem", "REQUESTS_CA_BUNDLE": "/requests-ca.pem"}
33+
{"SSL_CERT_FILE": self.ssl_ca, "REQUESTS_CA_BUNDLE": self.requests_ca}
2534
)
26-
self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/ssl-ca.pem")
35+
self.assertEqual(c.client.connection_pool_kw["ca_certs"], self.ssl_ca)
36+
37+
def test_missing_env_bundle_is_skipped_with_a_warning(self):
38+
with self.assertLogs("RESTClient", level="WARNING") as logs:
39+
c = self.client({"SSL_CERT_FILE": "/does-not-exist.pem"})
40+
self.assertEqual(c.client.connection_pool_kw["ca_certs"], certifi.where())
41+
self.assertEqual(c.vx.client.connection_pool_kw["ca_certs"], certifi.where())
42+
self.assertEqual(len(logs.output), 1, logs.output)
43+
self.assertIn("SSL_CERT_FILE", logs.output[0])
44+
with self.assertLogs("RESTClient", level="WARNING"):
45+
c = self.client(
46+
{
47+
"SSL_CERT_FILE": "/does-not-exist.pem",
48+
"REQUESTS_CA_BUNDLE": self.requests_ca,
49+
}
50+
)
51+
self.assertEqual(c.client.connection_pool_kw["ca_certs"], self.requests_ca)
2752

2853
def test_ca_certs_argument_wins_over_env(self):
29-
c = self.client({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem")
54+
c = self.client({"SSL_CERT_FILE": self.ssl_ca}, ca_certs="/explicit-ca.pem")
3055
self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/explicit-ca.pem")
3156

3257
def test_ca_certs_applies_to_vx(self):

‎test_websocket/test_ssl.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import tempfile
23
import unittest
34
from unittest import mock
45

@@ -8,6 +9,14 @@
89

910

1011
class WebSocketSSLTest(unittest.TestCase):
12+
def setUp(self):
13+
tmp = tempfile.TemporaryDirectory()
14+
self.addCleanup(tmp.cleanup)
15+
self.ssl_ca = os.path.join(tmp.name, "ssl-ca.pem")
16+
self.requests_ca = os.path.join(tmp.name, "requests-ca.pem")
17+
for path in (self.ssl_ca, self.requests_ca):
18+
open(path, "w").close()
19+
1120
def ca_path(self, env=None, **kwargs):
1221
with mock.patch.dict(os.environ, env or {}, clear=True):
1322
c = WebSocketClient(api_key="key", **kwargs)
@@ -19,14 +28,20 @@ def test_defaults_to_certifi(self):
1928
self.assertEqual(self.ca_path(), certifi.where())
2029

2130
def test_env(self):
22-
self.assertEqual(self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}), "/ssl-ca.pem")
31+
self.assertEqual(self.ca_path({"SSL_CERT_FILE": self.ssl_ca}), self.ssl_ca)
2332
self.assertEqual(
24-
self.ca_path({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}), "/requests-ca.pem"
33+
self.ca_path({"REQUESTS_CA_BUNDLE": self.requests_ca}), self.requests_ca
2534
)
2635

36+
def test_missing_env_bundle_is_skipped_with_a_warning(self):
37+
with self.assertLogs("WebSocketClient", level="WARNING") as logs:
38+
path = self.ca_path({"SSL_CERT_FILE": "/does-not-exist.pem"})
39+
self.assertEqual(path, certifi.where())
40+
self.assertIn("SSL_CERT_FILE", logs.output[0])
41+
2742
def test_ca_certs_argument_wins_over_env(self):
2843
self.assertEqual(
29-
self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem"),
44+
self.ca_path({"SSL_CERT_FILE": self.ssl_ca}, ca_certs="/explicit-ca.pem"),
3045
"/explicit-ca.pem",
3146
)
3247

0 commit comments

Comments
 (0)