Skip to content

Commit 6ea2ce7

Browse files
committed
ssl: avoid insecure pyopenssl fallback
1 parent 7c6f0d5 commit 6ea2ce7

8 files changed

Lines changed: 227 additions & 48 deletions

File tree

cassandra/connection.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,81 @@ def decompress(byts):
229229
frame_header_v3 = struct.Struct('>BhBi')
230230

231231

232+
def _default_pyopenssl_ssl_method(ssl_module):
233+
for method_name in ('TLS_CLIENT_METHOD', 'TLS_METHOD', 'TLSv1_2_METHOD'):
234+
method = getattr(ssl_module, method_name, None)
235+
if method is not None:
236+
return method
237+
raise ImportError('pyOpenSSL does not expose a secure TLS client method')
238+
239+
240+
def _pyopenssl_ssl_method_from_stdlib(ssl_module, ssl_version):
241+
if ssl_version is None:
242+
return _default_pyopenssl_ssl_method(ssl_module)
243+
244+
protocol_method_names = (
245+
('PROTOCOL_TLS_CLIENT', ('TLS_CLIENT_METHOD', 'TLS_METHOD', 'TLSv1_2_METHOD')),
246+
('PROTOCOL_TLS', ('TLS_METHOD', 'TLS_CLIENT_METHOD', 'TLSv1_2_METHOD')),
247+
('PROTOCOL_SSLv23', ('TLS_METHOD', 'TLS_CLIENT_METHOD', 'TLSv1_2_METHOD')),
248+
('PROTOCOL_TLSv1_2', ('TLSv1_2_METHOD',)),
249+
('PROTOCOL_TLSv1_1', ('TLSv1_1_METHOD',)),
250+
('PROTOCOL_TLSv1', ('TLSv1_METHOD',)),
251+
)
252+
for protocol_name, method_names in protocol_method_names:
253+
protocol = getattr(ssl, protocol_name, None)
254+
if (protocol is not None and
255+
ssl_version.__class__ is protocol.__class__ and
256+
ssl_version == protocol):
257+
for method_name in method_names:
258+
method = getattr(ssl_module, method_name, None)
259+
if method is not None:
260+
return method
261+
raise ImportError('pyOpenSSL does not expose a method for %s' % (protocol_name,))
262+
263+
return ssl_version
264+
265+
266+
def _pyopenssl_verify_mode_from_cert_reqs(ssl_module, cert_reqs):
267+
if cert_reqs is None:
268+
return None
269+
if cert_reqs.__class__ is not type(ssl.CERT_REQUIRED):
270+
return cert_reqs
271+
if cert_reqs == ssl.CERT_NONE:
272+
return ssl_module.VERIFY_NONE
273+
if cert_reqs in (ssl.CERT_OPTIONAL, ssl.CERT_REQUIRED):
274+
return ssl_module.VERIFY_PEER
275+
return cert_reqs
276+
277+
278+
def _build_pyopenssl_context_from_options(ssl_module, ssl_options):
279+
ssl_options = ssl_options or {}
280+
context = ssl_module.Context(
281+
_pyopenssl_ssl_method_from_stdlib(ssl_module, ssl_options.get('ssl_version', None))
282+
)
283+
if 'certfile' in ssl_options:
284+
context.use_certificate_file(ssl_options['certfile'])
285+
if 'keyfile' in ssl_options:
286+
context.use_privatekey_file(ssl_options['keyfile'])
287+
if 'ca_certs' in ssl_options:
288+
context.load_verify_locations(ssl_options['ca_certs'])
289+
cert_reqs = _pyopenssl_verify_mode_from_cert_reqs(
290+
ssl_module, ssl_options.get('cert_reqs', None))
291+
if cert_reqs is None:
292+
cert_reqs = (ssl_module.VERIFY_PEER
293+
if (ssl_options.get('ca_certs', None) or ssl_options.get('check_hostname', False))
294+
else ssl_module.VERIFY_NONE)
295+
context.set_verify(
296+
cert_reqs,
297+
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
298+
)
299+
ciphers = ssl_options.get('ciphers', None)
300+
if ciphers:
301+
if isinstance(ciphers, str):
302+
ciphers = ciphers.encode('ascii')
303+
context.set_cipher_list(ciphers)
304+
return context
305+
306+
232307
class EndPoint(object):
233308
"""
234309
Represents the information to connect to a cassandra node.

cassandra/datastax/cloud/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from zipfile import BadZipfile as BadZipFile
3535

3636
from cassandra import DriverException
37+
from cassandra.connection import _default_pyopenssl_ssl_method
3738

3839
log = logging.getLogger(__name__)
3940

@@ -183,10 +184,10 @@ def _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location):
183184
raise ImportError(
184185
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops")\
185186
.with_traceback(e.__traceback__)
186-
ssl_context = SSL.Context(SSL.TLSv1_METHOD)
187+
ssl_context = SSL.Context(_default_pyopenssl_ssl_method(SSL))
187188
ssl_context.set_verify(SSL.VERIFY_PEER, callback=lambda _1, _2, _3, _4, ok: ok)
188189
ssl_context.use_certificate_file(cert_location)
189190
ssl_context.use_privatekey_file(key_location)
190191
ssl_context.load_verify_locations(ca_cert_location)
191192

192-
return ssl_context
193+
return ssl_context

cassandra/io/eventletreactor.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import time
2525

2626
from cassandra.connection import (Connection, ConnectionShutdown, Timer, TimerManager,
27+
_build_pyopenssl_context_from_options as _build_pyopenssl_context,
28+
_default_pyopenssl_ssl_method,
2729
_validate_pyopenssl_hostname)
2830
try:
2931
from eventlet.green.OpenSSL import SSL
@@ -37,8 +39,7 @@
3739

3840

3941
def _default_ssl_method():
40-
return getattr(SSL, 'TLS_CLIENT_METHOD',
41-
getattr(SSL, 'TLS_METHOD', SSL.TLSv1_METHOD))
42+
return _default_pyopenssl_ssl_method(SSL)
4243

4344

4445
def _check_pyopenssl():
@@ -50,25 +51,7 @@ def _check_pyopenssl():
5051

5152

5253
def _build_pyopenssl_context_from_options(ssl_options):
53-
ssl_version = (ssl_options['ssl_version']
54-
if 'ssl_version' in ssl_options else _default_ssl_method())
55-
context = SSL.Context(ssl_version)
56-
if 'certfile' in ssl_options:
57-
context.use_certificate_file(ssl_options['certfile'])
58-
if 'keyfile' in ssl_options:
59-
context.use_privatekey_file(ssl_options['keyfile'])
60-
if 'ca_certs' in ssl_options:
61-
context.load_verify_locations(ssl_options['ca_certs'])
62-
cert_reqs = ssl_options.get('cert_reqs', None)
63-
if cert_reqs is None:
64-
cert_reqs = (SSL.VERIFY_PEER
65-
if (ssl_options.get('ca_certs', None) or ssl_options.get('check_hostname', False))
66-
else SSL.VERIFY_NONE)
67-
context.set_verify(
68-
cert_reqs,
69-
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
70-
)
71-
return context
54+
return _build_pyopenssl_context(SSL, ssl_options)
7255

7356

7457
class EventletConnection(Connection):

cassandra/io/twistedreactor.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from zope.interface import implementer
3030

3131
from cassandra.connection import (Connection, ConnectionShutdown, Timer, TimerManager, ConnectionException,
32+
_build_pyopenssl_context_from_options as _build_pyopenssl_context,
33+
_default_pyopenssl_ssl_method,
3234
_validate_pyopenssl_hostname)
3335

3436
try:
@@ -41,8 +43,7 @@
4143

4244

4345
def _default_ssl_method():
44-
return getattr(SSL, "TLS_CLIENT_METHOD",
45-
getattr(SSL, "TLS_METHOD", SSL.TLSv1_METHOD))
46+
return _default_pyopenssl_ssl_method(SSL)
4647

4748

4849
def _check_pyopenssl():
@@ -54,25 +55,7 @@ def _check_pyopenssl():
5455

5556

5657
def _build_pyopenssl_context_from_options(ssl_options):
57-
ssl_version = (ssl_options["ssl_version"]
58-
if "ssl_version" in ssl_options else _default_ssl_method())
59-
context = SSL.Context(ssl_version)
60-
if "certfile" in ssl_options:
61-
context.use_certificate_file(ssl_options["certfile"])
62-
if "keyfile" in ssl_options:
63-
context.use_privatekey_file(ssl_options["keyfile"])
64-
if "ca_certs" in ssl_options:
65-
context.load_verify_locations(ssl_options["ca_certs"])
66-
cert_reqs = ssl_options.get("cert_reqs", None)
67-
if cert_reqs is None:
68-
cert_reqs = (SSL.VERIFY_PEER
69-
if (ssl_options.get("ca_certs", None) or ssl_options.get("check_hostname", False))
70-
else SSL.VERIFY_NONE)
71-
context.set_verify(
72-
cert_reqs,
73-
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
74-
)
75-
return context
58+
return _build_pyopenssl_context(SSL, ssl_options)
7659

7760

7861
def _cleanup(cleanup_weakref):

tests/unit/io/test_eventletreactor.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
# limitations under the License.
1414

1515
import os
16+
import ssl
1617
import unittest
1718
from datetime import datetime, timedelta, timezone
19+
from types import SimpleNamespace
1820

1921
from unittest.mock import Mock, patch
2022

@@ -85,12 +87,31 @@ def test_empty_ssl_options_default_to_negotiating_tls(self):
8587
context_mock.assert_called_once_with(eventletreactor.SSL.TLS_CLIENT_METHOD)
8688
assert context is context_mock.return_value
8789

90+
def test_default_ssl_method_falls_back_to_tls_method(self):
91+
tls_method = object()
92+
93+
with patch.object(eventletreactor, 'SSL', SimpleNamespace(TLS_METHOD=tls_method)):
94+
assert eventletreactor._default_ssl_method() is tls_method
95+
96+
def test_default_ssl_method_falls_back_to_tlsv1_2_method(self):
97+
tlsv1_2_method = object()
98+
99+
with patch.object(eventletreactor, 'SSL', SimpleNamespace(TLSv1_2_METHOD=tlsv1_2_method)):
100+
assert eventletreactor._default_ssl_method() is tlsv1_2_method
101+
88102
def test_ssl_version_option_is_preserved(self):
89103
with patch.object(eventletreactor.SSL, 'Context') as context_mock:
90104
eventletreactor._build_pyopenssl_context_from_options(
91-
{'ssl_version': eventletreactor.SSL.TLSv1_METHOD})
105+
{'ssl_version': eventletreactor.SSL.TLSv1_2_METHOD})
92106

93-
context_mock.assert_called_once_with(eventletreactor.SSL.TLSv1_METHOD)
107+
context_mock.assert_called_once_with(eventletreactor.SSL.TLSv1_2_METHOD)
108+
109+
def test_stdlib_ssl_version_option_is_translated(self):
110+
with patch.object(eventletreactor.SSL, 'Context') as context_mock:
111+
eventletreactor._build_pyopenssl_context_from_options(
112+
{'ssl_version': ssl.PROTOCOL_TLS})
113+
114+
context_mock.assert_called_once_with(eventletreactor.SSL.TLS_METHOD)
94115

95116
def test_ca_certs_default_to_required_validation(self):
96117
conn = EventletConnection.__new__(EventletConnection)
@@ -99,6 +120,17 @@ def test_ca_certs_default_to_required_validation(self):
99120

100121
assert conn.ssl_context.get_verify_mode() == eventletreactor.SSL.VERIFY_PEER
101122

123+
def test_stdlib_cert_reqs_option_is_translated(self):
124+
context = eventletreactor._build_pyopenssl_context_from_options({'cert_reqs': ssl.CERT_REQUIRED})
125+
126+
assert context.get_verify_mode() == eventletreactor.SSL.VERIFY_PEER
127+
128+
def test_ciphers_option_is_applied(self):
129+
with patch.object(eventletreactor.SSL, 'Context') as context_mock:
130+
eventletreactor._build_pyopenssl_context_from_options({'ciphers': 'ECDHE+AESGCM'})
131+
132+
context_mock.return_value.set_cipher_list.assert_called_once_with(b'ECDHE+AESGCM')
133+
102134
def test_check_hostname_option_enables_hostname_validation(self):
103135
conn = EventletConnection.__new__(EventletConnection)
104136

tests/unit/io/test_twistedreactor.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
# limitations under the License.
1414

1515
import os
16+
import ssl
1617
import unittest
1718
from datetime import datetime, timedelta, timezone
19+
from types import SimpleNamespace
1820
from unittest.mock import Mock, patch
1921

2022
try:
@@ -76,18 +78,48 @@ def test_empty_ssl_options_default_to_negotiating_tls(self):
7678
context_mock.assert_called_once_with(twistedreactor.SSL.TLS_CLIENT_METHOD)
7779
assert context is context_mock.return_value
7880

81+
def test_default_ssl_method_falls_back_to_tls_method(self):
82+
tls_method = object()
83+
84+
with patch.object(twistedreactor, 'SSL', SimpleNamespace(TLS_METHOD=tls_method)):
85+
assert twistedreactor._default_ssl_method() is tls_method
86+
87+
def test_default_ssl_method_falls_back_to_tlsv1_2_method(self):
88+
tlsv1_2_method = object()
89+
90+
with patch.object(twistedreactor, 'SSL', SimpleNamespace(TLSv1_2_METHOD=tlsv1_2_method)):
91+
assert twistedreactor._default_ssl_method() is tlsv1_2_method
92+
7993
def test_ssl_version_option_is_preserved(self):
8094
with patch.object(twistedreactor.SSL, 'Context') as context_mock:
8195
twistedreactor._build_pyopenssl_context_from_options(
82-
{'ssl_version': twistedreactor.SSL.TLSv1_METHOD})
96+
{'ssl_version': twistedreactor.SSL.TLSv1_2_METHOD})
97+
98+
context_mock.assert_called_once_with(twistedreactor.SSL.TLSv1_2_METHOD)
99+
100+
def test_stdlib_ssl_version_option_is_translated(self):
101+
with patch.object(twistedreactor.SSL, 'Context') as context_mock:
102+
twistedreactor._build_pyopenssl_context_from_options(
103+
{'ssl_version': ssl.PROTOCOL_TLS})
83104

84-
context_mock.assert_called_once_with(twistedreactor.SSL.TLSv1_METHOD)
105+
context_mock.assert_called_once_with(twistedreactor.SSL.TLS_METHOD)
85106

86107
def test_ca_certs_default_to_required_validation(self):
87108
context = twistedreactor._build_pyopenssl_context_from_options({'ca_certs': CA_CERTS})
88109

89110
assert context.get_verify_mode() == twistedreactor.SSL.VERIFY_PEER
90111

112+
def test_stdlib_cert_reqs_option_is_translated(self):
113+
context = twistedreactor._build_pyopenssl_context_from_options({'cert_reqs': ssl.CERT_REQUIRED})
114+
115+
assert context.get_verify_mode() == twistedreactor.SSL.VERIFY_PEER
116+
117+
def test_ciphers_option_is_applied(self):
118+
with patch.object(twistedreactor.SSL, 'Context') as context_mock:
119+
twistedreactor._build_pyopenssl_context_from_options({'ciphers': 'ECDHE+AESGCM'})
120+
121+
context_mock.return_value.set_cipher_list.assert_called_once_with(b'ECDHE+AESGCM')
122+
91123
def test_check_hostname_option_enables_hostname_validation(self):
92124
conn = TwistedConnection.__new__(TwistedConnection)
93125

tests/unit/test_cloud.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright DataStax, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from types import SimpleNamespace
16+
17+
import pytest
18+
19+
from cassandra.datastax import cloud
20+
21+
22+
def test_default_pyopenssl_ssl_method_prefers_tls_client_method():
23+
tls_client_method = object()
24+
25+
ssl_module = SimpleNamespace(
26+
TLS_CLIENT_METHOD=tls_client_method,
27+
TLS_METHOD=object(),
28+
TLSv1_2_METHOD=object())
29+
30+
assert cloud._default_pyopenssl_ssl_method(ssl_module) is tls_client_method
31+
32+
33+
def test_default_pyopenssl_ssl_method_falls_back_to_tls_method():
34+
tls_method = object()
35+
36+
ssl_module = SimpleNamespace(
37+
TLS_METHOD=tls_method,
38+
TLSv1_2_METHOD=object())
39+
40+
assert cloud._default_pyopenssl_ssl_method(ssl_module) is tls_method
41+
42+
43+
def test_default_pyopenssl_ssl_method_falls_back_to_tlsv1_2_method():
44+
tlsv1_2_method = object()
45+
46+
ssl_module = SimpleNamespace(TLSv1_2_METHOD=tlsv1_2_method)
47+
48+
assert cloud._default_pyopenssl_ssl_method(ssl_module) is tlsv1_2_method
49+
50+
51+
def test_default_pyopenssl_ssl_method_requires_secure_method():
52+
with pytest.raises(ImportError, match="secure TLS client method"):
53+
cloud._default_pyopenssl_ssl_method(SimpleNamespace())

0 commit comments

Comments
 (0)