Skip to content

Commit 41e4b00

Browse files
authored
Stop providing a hardcoded CA bundle (#489)
The SDK no longer provides a CA bundle to verify SSL connections. This also allows us to remove the runtime dependency on `pkg_resources` and thus `setuptools`. The `ca_certs` parameter is still supported, so users can pin with their own CA bundle if they so choose. Otherwise, the default verification mechanism in the `requests` library now applies (this uses `certifi` and/or system certificates, depending on the configuration). Improves integration tests to cover both scenarios (i.e. when a bundle is provided, and when one isn't).
1 parent 75596da commit 41e4b00

File tree

7 files changed

+413
-1456
lines changed

7 files changed

+413
-1456
lines changed

dropbox/dropbox_client.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,8 @@ def __init__(self,
181181
Not required if PKCE was used to authorize the token
182182
:param list scope: list of scopes to request on refresh. If left blank,
183183
refresh will request all available scopes for application
184-
:param str ca_certs: path to CA certificate. If left blank, default certificate location \
185-
will be used
184+
:param str ca_certs: a path to a file of concatenated CA certificates in PEM format.
185+
Has the same meaning as when using :func:`ssl.wrap_socket`.
186186
"""
187187

188188
if not (oauth2_access_token or oauth2_refresh_token or (app_key and app_secret)):
@@ -590,7 +590,6 @@ def request_json_string(self,
590590
headers=headers,
591591
data=body,
592592
stream=stream,
593-
verify=True,
594593
timeout=timeout,
595594
)
596595
self.raise_dropbox_error_for_resp(r)

dropbox/session.py

+14-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import pkg_resources
21
import os
32
import ssl
43

@@ -32,21 +31,14 @@
3231
# This is the default longest time we'll block on receiving data from the server
3332
DEFAULT_TIMEOUT = 100
3433

35-
try:
36-
_TRUSTED_CERT_FILE = pkg_resources.resource_filename(__name__, 'trusted-certs.crt')
37-
except NotImplementedError: # Package is used inside python archive
38-
_TRUSTED_CERT_FILE = None
39-
4034

4135
# TODO(kelkabany): We probably only want to instantiate this once so that even
4236
# if multiple Dropbox objects are instantiated, they all share the same pool.
4337
class _SSLAdapter(HTTPAdapter):
4438
_ca_certs = None
4539

4640
def __init__(self, *args, **kwargs):
47-
self._ca_certs = kwargs.pop("ca_certs", None) or _TRUSTED_CERT_FILE
48-
if not self._ca_certs:
49-
raise AttributeError("CA certificate not set")
41+
self._ca_certs = kwargs.pop("ca_certs", None)
5042
super(_SSLAdapter, self).__init__(*args, **kwargs)
5143

5244
def init_poolmanager(self, connections, maxsize, block=False, **_):
@@ -59,8 +51,19 @@ def init_poolmanager(self, connections, maxsize, block=False, **_):
5951
)
6052

6153
def pinned_session(pool_maxsize=8, ca_certs=None):
62-
http_adapter = _SSLAdapter(pool_connections=4, pool_maxsize=pool_maxsize, ca_certs=ca_certs)
54+
# always verify, use cert bundle if provided
55+
6356
_session = requests.session()
64-
_session.mount('https://', http_adapter)
6557

58+
# requests
59+
if ca_certs is not None:
60+
_session.verify = ca_certs
61+
else:
62+
_session.verify = True
63+
64+
# urllib3 within requests
65+
http_adapter = _SSLAdapter(pool_connections=4, pool_maxsize=pool_maxsize, ca_certs=ca_certs)
66+
_session.mount('https://', http_adapter)
6667
return _session
68+
69+
SSLError = requests.exceptions.SSLError # raised on verification errors

dropbox/trusted-certs.crt

-1,396
This file was deleted.

setup.py

-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
setup_requires=setup_requires,
5555
tests_require=test_reqs,
5656
packages=['dropbox'],
57-
package_data={'dropbox': ['trusted-certs.crt']},
5857
zip_safe=False,
5958
author_email='[email protected]',
6059
author='Dropbox',

test/integration/expired-certs.crt

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# GeoTrust Global CA.pem
2+
# Certificate:
3+
# Data:
4+
# Version: 3 (0x2)
5+
# Serial Number: 144470 (0x23456)
6+
# Signature Algorithm: sha1WithRSAEncryption
7+
# Issuer: C=US, O=GeoTrust Inc., CN=GeoTrust Global CA
8+
# Validity
9+
# Not Before: May 21 04:00:00 2002 GMT
10+
# Not After : May 21 04:00:00 2022 GMT
11+
# Subject: C=US, O=GeoTrust Inc., CN=GeoTrust Global CA
12+
# Subject Public Key Info:
13+
# Public Key Algorithm: rsaEncryption
14+
# Public-Key: (2048 bit)
15+
# Modulus:
16+
# 00:da:cc:18:63:30:fd:f4:17:23:1a:56:7e:5b:df:
17+
# 3c:6c:38:e4:71:b7:78:91:d4:bc:a1:d8:4c:f8:a8:
18+
# 43:b6:03:e9:4d:21:07:08:88:da:58:2f:66:39:29:
19+
# bd:05:78:8b:9d:38:e8:05:b7:6a:7e:71:a4:e6:c4:
20+
# 60:a6:b0:ef:80:e4:89:28:0f:9e:25:d6:ed:83:f3:
21+
# ad:a6:91:c7:98:c9:42:18:35:14:9d:ad:98:46:92:
22+
# 2e:4f:ca:f1:87:43:c1:16:95:57:2d:50:ef:89:2d:
23+
# 80:7a:57:ad:f2:ee:5f:6b:d2:00:8d:b9:14:f8:14:
24+
# 15:35:d9:c0:46:a3:7b:72:c8:91:bf:c9:55:2b:cd:
25+
# d0:97:3e:9c:26:64:cc:df:ce:83:19:71:ca:4e:e6:
26+
# d4:d5:7b:a9:19:cd:55:de:c8:ec:d2:5e:38:53:e5:
27+
# 5c:4f:8c:2d:fe:50:23:36:fc:66:e6:cb:8e:a4:39:
28+
# 19:00:b7:95:02:39:91:0b:0e:fe:38:2e:d1:1d:05:
29+
# 9a:f6:4d:3e:6f:0f:07:1d:af:2c:1e:8f:60:39:e2:
30+
# fa:36:53:13:39:d4:5e:26:2b:db:3d:a8:14:bd:32:
31+
# eb:18:03:28:52:04:71:e5:ab:33:3d:e1:38:bb:07:
32+
# 36:84:62:9c:79:ea:16:30:f4:5f:c0:2b:e8:71:6b:
33+
# e4:f9
34+
# Exponent: 65537 (0x10001)
35+
# X509v3 extensions:
36+
# X509v3 Basic Constraints: critical
37+
# CA:TRUE
38+
# X509v3 Subject Key Identifier:
39+
# C0:7A:98:68:8D:89:FB:AB:05:64:0C:11:7D:AA:7D:65:B8:CA:CC:4E
40+
# X509v3 Authority Key Identifier:
41+
# keyid:C0:7A:98:68:8D:89:FB:AB:05:64:0C:11:7D:AA:7D:65:B8:CA:CC:4E
42+
#
43+
# Signature Algorithm: sha1WithRSAEncryption
44+
# 35:e3:29:6a:e5:2f:5d:54:8e:29:50:94:9f:99:1a:14:e4:8f:
45+
# 78:2a:62:94:a2:27:67:9e:d0:cf:1a:5e:47:e9:c1:b2:a4:cf:
46+
# dd:41:1a:05:4e:9b:4b:ee:4a:6f:55:52:b3:24:a1:37:0a:eb:
47+
# 64:76:2a:2e:2c:f3:fd:3b:75:90:bf:fa:71:d8:c7:3d:37:d2:
48+
# b5:05:95:62:b9:a6:de:89:3d:36:7b:38:77:48:97:ac:a6:20:
49+
# 8f:2e:a6:c9:0c:c2:b2:99:45:00:c7:ce:11:51:22:22:e0:a5:
50+
# ea:b6:15:48:09:64:ea:5e:4f:74:f7:05:3e:c7:8a:52:0c:db:
51+
# 15:b4:bd:6d:9b:e5:c6:b1:54:68:a9:e3:69:90:b6:9a:a5:0f:
52+
# b8:b9:3f:20:7d:ae:4a:b5:b8:9c:e4:1d:b6:ab:e6:94:a5:c1:
53+
# c7:83:ad:db:f5:27:87:0e:04:6c:d5:ff:dd:a0:5d:ed:87:52:
54+
# b7:2b:15:02:ae:39:a6:6a:74:e9:da:c4:e7:bc:4d:34:1e:a9:
55+
# 5c:4d:33:5f:92:09:2f:88:66:5d:77:97:c7:1d:76:13:a9:d5:
56+
# e5:f1:16:09:11:35:d5:ac:db:24:71:70:2c:98:56:0b:d9:17:
57+
# b4:d1:e3:51:2b:5e:75:e8:d5:d0:dc:4f:34:ed:c2:05:66:80:
58+
# a1:cb:e6:33
59+
-----BEGIN CERTIFICATE-----
60+
MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT
61+
MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i
62+
YWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG
63+
EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg
64+
R2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9
65+
9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq
66+
fnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv
67+
iS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU
68+
1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+
69+
bw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW
70+
MPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA
71+
ephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l
72+
uMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn
73+
Z57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS
74+
tQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF
75+
PseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un
76+
hw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV
77+
5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw==
78+
-----END CERTIFICATE-----

test/integration/test_dropbox.py

+71-45
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from StringIO import StringIO as BytesIO
1717

1818
from dropbox import (
19+
create_session,
1920
Dropbox,
2021
DropboxOAuth2Flow,
2122
DropboxTeam,
@@ -39,6 +40,7 @@
3940
PathRoot,
4041
PathRoot_validator,
4142
)
43+
from dropbox.session import SSLError
4244

4345
# Key Types
4446
REFRESH_TOKEN_KEY = "REFRESH_TOKEN"
@@ -65,34 +67,43 @@ def _value_from_env_or_die(env_name):
6567
sys.exit(1)
6668
return value
6769

70+
_TRUSTED_CERTS_FILE = os.path.join(os.path.dirname(__file__), "trusted-certs.crt")
71+
_EXPIRED_CERTS_FILE = os.path.join(os.path.dirname(__file__), "expired-certs.crt")
72+
73+
# enables testing both with and without a manually-provided CA bundle
74+
@pytest.fixture(params=[None, _TRUSTED_CERTS_FILE], ids=["no-pinning", "pinning"])
75+
def dbx_session(request):
76+
return create_session(ca_certs=request.param)
77+
6878

6979
@pytest.fixture()
70-
def dbx_from_env():
80+
def dbx_from_env(dbx_session):
7181
oauth2_token = _value_from_env_or_die(format_env_name())
72-
return Dropbox(oauth2_token)
82+
return Dropbox(oauth2_token, session=dbx_session)
7383

7484

7585
@pytest.fixture()
76-
def refresh_dbx_from_env():
86+
def refresh_dbx_from_env(dbx_session):
7787
refresh_token = _value_from_env_or_die(format_env_name(SCOPED_KEY, USER_KEY, REFRESH_TOKEN_KEY))
7888
app_key = _value_from_env_or_die(format_env_name(SCOPED_KEY, USER_KEY, CLIENT_ID_KEY))
7989
app_secret = _value_from_env_or_die(format_env_name(SCOPED_KEY, USER_KEY, CLIENT_SECRET_KEY))
8090
return Dropbox(oauth2_refresh_token=refresh_token,
81-
app_key=app_key, app_secret=app_secret)
91+
app_key=app_key, app_secret=app_secret,
92+
session=dbx_session)
8293

8394

8495
@pytest.fixture()
85-
def dbx_team_from_env():
96+
def dbx_team_from_env(dbx_session):
8697
team_oauth2_token = _value_from_env_or_die(
8798
format_env_name(SCOPED_KEY, TEAM_KEY, ACCESS_TOKEN_KEY))
88-
return DropboxTeam(team_oauth2_token)
99+
return DropboxTeam(team_oauth2_token, session=dbx_session)
89100

90101

91102
@pytest.fixture()
92-
def dbx_app_auth_from_env():
103+
def dbx_app_auth_from_env(dbx_session):
93104
app_key = _value_from_env_or_die(format_env_name(SCOPED_KEY, USER_KEY, CLIENT_ID_KEY))
94105
app_secret = _value_from_env_or_die(format_env_name(SCOPED_KEY, USER_KEY, CLIENT_SECRET_KEY))
95-
return Dropbox(app_key=app_key, app_secret=app_secret)
106+
return Dropbox(app_key=app_key, app_secret=app_secret, session=dbx_session)
96107

97108

98109
@pytest.fixture()
@@ -110,7 +121,7 @@ def dbx_share_url_from_env():
110121
TIMESTAMP = str(datetime.datetime.utcnow())
111122
STATIC_FILE = "/test.txt"
112123

113-
@pytest.fixture(scope='module', autouse=True)
124+
@pytest.fixture(scope='module')
114125
def pytest_setup():
115126
print("Setup")
116127
dbx = Dropbox(_value_from_env_or_die(format_env_name()))
@@ -125,47 +136,14 @@ def pytest_setup():
125136
except Exception:
126137
print("File not found")
127138

128-
129139
@pytest.mark.usefixtures(
140+
"pytest_setup",
130141
"dbx_from_env",
131142
"refresh_dbx_from_env",
132143
"dbx_app_auth_from_env",
133-
"dbx_share_url_from_env"
144+
"dbx_share_url_from_env",
134145
)
135146
class TestDropbox:
136-
def test_default_oauth2_urls(self):
137-
flow_obj = DropboxOAuth2Flow('dummy_app_key', 'dummy_app_secret',
138-
'http://localhost/dummy', 'dummy_session', 'dbx-auth-csrf-token')
139-
140-
assert re.match(
141-
r'^https://{}/oauth2/authorize\?'.format(re.escape(session.WEB_HOST)),
142-
flow_obj._get_authorize_url('http://localhost/redirect', 'state', 'legacy'),
143-
)
144-
145-
assert flow_obj.build_url(
146-
'/oauth2/authorize'
147-
) == 'https://{}/oauth2/authorize'.format(session.API_HOST)
148-
149-
assert flow_obj.build_url(
150-
'/oauth2/authorize', host=session.WEB_HOST
151-
) == 'https://{}/oauth2/authorize'.format(session.WEB_HOST)
152-
153-
def test_bad_auth(self):
154-
# Test malformed token
155-
malformed_token_dbx = Dropbox(MALFORMED_TOKEN)
156-
# TODO: backend is no longer returning `BadInputError`
157-
# with pytest.raises(BadInputError,) as cm:
158-
# malformed_token_dbx.files_list_folder('')
159-
# assert 'token is malformed' in cm.value.message
160-
with pytest.raises(AuthError,):
161-
malformed_token_dbx.files_list_folder('')
162-
163-
# Test reasonable-looking invalid token
164-
invalid_token_dbx = Dropbox(INVALID_TOKEN)
165-
with pytest.raises(AuthError) as cm:
166-
invalid_token_dbx.files_list_folder('')
167-
assert cm.value.error.is_invalid_access_token()
168-
169147
def test_multi_auth(self, dbx_from_env, dbx_app_auth_from_env, dbx_share_url_from_env):
170148
# Test for user (with oauth token)
171149
preview_result, resp = dbx_from_env.files_get_thumbnail_v2(
@@ -280,7 +258,10 @@ def test_versioned_route(self, dbx_from_env):
280258
# Verify response type is of v2 route
281259
assert isinstance(resp, DeleteResult)
282260

283-
@pytest.mark.usefixtures("dbx_team_from_env")
261+
@pytest.mark.usefixtures(
262+
"pytest_setup",
263+
"dbx_team_from_env",
264+
)
284265
class TestDropboxTeam:
285266
def test_team(self, dbx_team_from_env):
286267
dbx_team_from_env.team_groups_list()
@@ -310,3 +291,48 @@ def test_clone_when_team_linked(self, dbx_team_from_env):
310291
new_dbxt = dbx_team_from_env.clone()
311292
assert dbx_team_from_env is not new_dbxt
312293
assert isinstance(new_dbxt, dbx_team_from_env.__class__)
294+
295+
def test_default_oauth2_urls():
296+
flow_obj = DropboxOAuth2Flow('dummy_app_key', 'dummy_app_secret',
297+
'http://localhost/dummy', 'dummy_session', 'dbx-auth-csrf-token')
298+
299+
assert re.match(
300+
r'^https://{}/oauth2/authorize\?'.format(re.escape(session.WEB_HOST)),
301+
flow_obj._get_authorize_url('http://localhost/redirect', 'state', 'legacy'),
302+
)
303+
304+
assert flow_obj.build_url(
305+
'/oauth2/authorize'
306+
) == 'https://{}/oauth2/authorize'.format(session.API_HOST)
307+
308+
assert flow_obj.build_url(
309+
'/oauth2/authorize', host=session.WEB_HOST
310+
) == 'https://{}/oauth2/authorize'.format(session.WEB_HOST)
311+
312+
def test_bad_auth(dbx_session):
313+
# Test malformed token
314+
malformed_token_dbx = Dropbox(MALFORMED_TOKEN, session=dbx_session)
315+
# TODO: backend is no longer returning `BadInputError`
316+
# with pytest.raises(BadInputError,) as cm:
317+
# malformed_token_dbx.files_list_folder('')
318+
# assert 'token is malformed' in cm.value.message
319+
with pytest.raises(AuthError):
320+
malformed_token_dbx.files_list_folder('')
321+
322+
# Test reasonable-looking invalid token
323+
invalid_token_dbx = Dropbox(INVALID_TOKEN, session=dbx_session)
324+
with pytest.raises(AuthError) as cm:
325+
invalid_token_dbx.files_list_folder('')
326+
assert cm.value.error.is_invalid_access_token()
327+
328+
def test_bad_pins():
329+
# sanity-check: if we're pinning using expired pins, we should fail w/ an SSL error
330+
_dbx = Dropbox("dummy_token", ca_certs=_EXPIRED_CERTS_FILE)
331+
with pytest.raises(SSLError,):
332+
_dbx.files_list_folder('')
333+
334+
def test_bad_pins_session():
335+
_session = create_session(ca_certs=_EXPIRED_CERTS_FILE)
336+
_dbx = Dropbox("dummy_token2", session=_session)
337+
with pytest.raises(SSLError,):
338+
_dbx.files_list_folder('')

0 commit comments

Comments
 (0)