Skip to content

Commit ab39732

Browse files
committed
connection: fix ssl_options TLS handling
Preserve the distinction between omitted and explicitly empty ssl_options across connection setup, reactors, shard-aware routing, cloud contexts, and Insights reporting. Strengthen pyOpenSSL protocol, verification, SNI, and hostname handling while retaining legacy option behavior.
1 parent bcc2d3d commit ab39732

18 files changed

Lines changed: 1306 additions & 99 deletions

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
Unreleased
22
==========
33

4+
Bug Fixes
5+
---------
6+
* Explicit ``ssl_options={}`` and endpoint SSL options now enable TLS instead
7+
of being treated as omitted SSL options. An empty dict encrypts the
8+
connection without server certificate verification unless verification
9+
options such as ``ca_certs`` or ``cert_reqs`` are supplied.
10+
* Legacy ``ssl_options`` now work with the Twisted and Eventlet reactors by
11+
using pyOpenSSL contexts with mapped protocol, verification, cipher, SNI, and
12+
hostname-validation settings.
13+
* ``SSLContext`` setup now applies ``cert_reqs`` through ``verify_mode`` instead
14+
of overwriting the SSL options bitmask, preserving default hardening flags.
15+
416
Others
517
------
618
* Message serialization now receives the connection's negotiated ``ProtocolFeatures``:

cassandra/cluster.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,15 @@ def default_retry_policy(self, policy):
885885
886886
The following documentation only applies when ssl_options is used without ssl_context.
887887
888+
.. versionchanged:: 3.29.12
889+
890+
An explicit empty dict (``ssl_options={}``) enables TLS with default
891+
options, while ``ssl_options=None`` leaves the connection in plaintext.
892+
Empty options do not supply ``ca_certs`` or ``cert_reqs``, so the
893+
encrypted connection does not verify the server certificate. Supply
894+
``ca_certs`` and, when needed, ``'check_hostname': True`` to authenticate
895+
the server.
896+
888897
By default, a ``ca_certs`` value should be supplied (the value should be
889898
a string pointing to the location of the CA certs file), and you probably
890899
want to specify ``ssl_version`` as ``ssl.PROTOCOL_TLS`` to match
@@ -1298,7 +1307,8 @@ def __init__(self,
12981307

12991308
if cloud is not None:
13001309
self.cloud = cloud
1301-
if contact_points is not _NOT_SET or endpoint_factory or ssl_context or ssl_options:
1310+
if (contact_points is not _NOT_SET or endpoint_factory or
1311+
ssl_context is not None or ssl_options is not None):
13021312
raise ValueError("contact_points, endpoint_factory, ssl_context, and ssl_options "
13031313
"cannot be specified with a cloud configuration")
13041314

@@ -1508,7 +1518,7 @@ def __init__(self,
15081518

15091519
self.metrics_enabled = metrics_enabled
15101520

1511-
if ssl_options and not ssl_context:
1521+
if ssl_options is not None and ssl_context is None:
15121522
warn('Using ssl_options without ssl_context is '
15131523
'deprecated and will result in an error in '
15141524
'the next major release. Please use ssl_context '

cassandra/connection.py

Lines changed: 225 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import errno
1818
from functools import wraps, partial, total_ordering
1919
from heapq import heappush, heappop
20+
import ipaddress
2021
import io
2122
import logging
2223
import socket
@@ -130,6 +131,201 @@ def decompress(byts):
130131
frame_header_v3 = struct.Struct('>BhBi')
131132

132133

134+
def _default_pyopenssl_ssl_method(ssl_module):
135+
for method_name in ('TLS_CLIENT_METHOD', 'TLS_METHOD', 'TLSv1_2_METHOD'):
136+
method = getattr(ssl_module, method_name, None)
137+
if method is not None:
138+
return method
139+
raise ImportError('pyOpenSSL does not expose a secure TLS client method')
140+
141+
142+
def _pyopenssl_ssl_method_from_stdlib(ssl_module, ssl_version):
143+
if ssl_version is None:
144+
return _default_pyopenssl_ssl_method(ssl_module)
145+
146+
protocol_method_names = (
147+
('PROTOCOL_TLS_CLIENT', ('TLS_CLIENT_METHOD', 'TLS_METHOD', 'TLSv1_2_METHOD')),
148+
('PROTOCOL_TLS', ('TLS_METHOD', 'TLS_CLIENT_METHOD', 'TLSv1_2_METHOD')),
149+
('PROTOCOL_SSLv23', ('TLS_METHOD', 'TLS_CLIENT_METHOD', 'TLSv1_2_METHOD')),
150+
('PROTOCOL_TLSv1_2', ('TLSv1_2_METHOD',)),
151+
('PROTOCOL_TLSv1_1', ('TLSv1_1_METHOD',)),
152+
('PROTOCOL_TLSv1', ('TLSv1_METHOD',)),
153+
)
154+
for protocol_name, method_names in protocol_method_names:
155+
protocol = getattr(ssl, protocol_name, None)
156+
if (protocol is not None and
157+
ssl_version.__class__ is protocol.__class__ and
158+
ssl_version == protocol):
159+
for method_name in method_names:
160+
method = getattr(ssl_module, method_name, None)
161+
if method is not None:
162+
return method
163+
raise ImportError('pyOpenSSL does not expose a method for %s' % (protocol_name,))
164+
165+
return ssl_version
166+
167+
168+
def _pyopenssl_verify_mode_from_cert_reqs(ssl_module, cert_reqs):
169+
if cert_reqs is None:
170+
return None
171+
if cert_reqs == ssl.CERT_NONE:
172+
return ssl_module.VERIFY_NONE
173+
if cert_reqs in (ssl.CERT_OPTIONAL, ssl.CERT_REQUIRED):
174+
return ssl_module.VERIFY_PEER
175+
return cert_reqs
176+
177+
178+
def _ensure_pyopenssl_context_requires_verification(ssl_module, context, check_hostname):
179+
get_verify_mode = getattr(context, 'get_verify_mode', None)
180+
if (check_hostname and get_verify_mode is not None and
181+
get_verify_mode() == ssl_module.VERIFY_NONE):
182+
context.set_verify(
183+
ssl_module.VERIFY_PEER,
184+
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
185+
)
186+
187+
188+
def _build_pyopenssl_context_from_options(ssl_module, ssl_options):
189+
ssl_options = ssl_options or {}
190+
context = ssl_module.Context(
191+
_pyopenssl_ssl_method_from_stdlib(ssl_module, ssl_options.get('ssl_version', None))
192+
)
193+
if 'certfile' in ssl_options:
194+
context.use_certificate_file(ssl_options['certfile'])
195+
if 'keyfile' in ssl_options:
196+
context.use_privatekey_file(ssl_options['keyfile'])
197+
if 'ca_certs' in ssl_options:
198+
context.load_verify_locations(ssl_options['ca_certs'])
199+
cert_reqs = _pyopenssl_verify_mode_from_cert_reqs(
200+
ssl_module, ssl_options.get('cert_reqs', None))
201+
if cert_reqs is None:
202+
cert_reqs = (ssl_module.VERIFY_PEER
203+
if ssl_options
204+
else ssl_module.VERIFY_NONE)
205+
elif ssl_options.get('check_hostname', False) and cert_reqs == ssl_module.VERIFY_NONE:
206+
cert_reqs = ssl_module.VERIFY_PEER
207+
context.set_verify(
208+
cert_reqs,
209+
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
210+
)
211+
ciphers = ssl_options.get('ciphers', None)
212+
if ciphers:
213+
if isinstance(ciphers, str):
214+
ciphers = ciphers.encode('ascii')
215+
context.set_cipher_list(ciphers)
216+
return context
217+
218+
219+
def _normalized_hostname(hostname):
220+
return hostname.rstrip('.').lower()
221+
222+
223+
def _dnsname_match(dn, hostname):
224+
dn = _normalized_hostname(dn)
225+
hostname = _normalized_hostname(hostname)
226+
227+
if not dn or not hostname:
228+
return False
229+
230+
if '*' not in dn:
231+
return dn == hostname
232+
233+
dn_labels = dn.split('.')
234+
hostname_labels = hostname.split('.')
235+
if (len(dn_labels) != len(hostname_labels) or
236+
len(dn_labels) < 2 or dn_labels[0] != '*' or
237+
not hostname_labels[0] or
238+
any('*' in label for label in dn_labels[1:])):
239+
return False
240+
241+
return dn_labels[1:] == hostname_labels[1:]
242+
243+
244+
def _ipaddress_match(cert_ip, hostname):
245+
try:
246+
host_ip = ipaddress.ip_address(hostname)
247+
cert_ip = ipaddress.ip_address(cert_ip)
248+
except ValueError:
249+
return False
250+
return cert_ip == host_ip
251+
252+
253+
def _is_ip_address(hostname):
254+
try:
255+
ipaddress.ip_address(hostname)
256+
except ValueError:
257+
return False
258+
return True
259+
260+
261+
def _decode_x509_name(value):
262+
if isinstance(value, bytes):
263+
return value.decode('utf-8')
264+
return value
265+
266+
267+
def _pyopenssl_cert_subject_alt_names(cert):
268+
dns_names = []
269+
ip_addresses = []
270+
271+
for i in range(cert.get_extension_count()):
272+
extension = cert.get_extension(i)
273+
if extension.get_short_name() != b'subjectAltName':
274+
continue
275+
for item in str(extension).split(','):
276+
item = item.strip()
277+
if item.startswith('DNS:'):
278+
dns_names.append(item[4:])
279+
elif item.startswith('IP Address:'):
280+
ip_addresses.append(item[11:])
281+
282+
return dns_names, ip_addresses
283+
284+
285+
def _pyopenssl_cert_common_names(cert):
286+
return [
287+
_decode_x509_name(value)
288+
for key, value in cert.get_subject().get_components()
289+
if key == b'CN'
290+
]
291+
292+
293+
def _validate_pyopenssl_hostname(cert, hostname):
294+
if cert is None:
295+
raise ssl.CertificateError(
296+
"peer did not present a certificate; cannot verify hostname %r" % (hostname,))
297+
298+
san_dns_names, san_ip_addresses = _pyopenssl_cert_subject_alt_names(cert)
299+
san_names = san_dns_names + san_ip_addresses
300+
hostname_is_ip = _is_ip_address(hostname)
301+
302+
for cert_ip in san_ip_addresses:
303+
if _ipaddress_match(cert_ip, hostname):
304+
return
305+
if hostname_is_ip and san_names:
306+
raise ssl.CertificateError(
307+
"hostname %r doesn't match certificate subjectAltName %r" %
308+
(hostname, san_names))
309+
310+
for cert_hostname in san_dns_names:
311+
if _dnsname_match(cert_hostname, hostname):
312+
return
313+
if san_names:
314+
raise ssl.CertificateError(
315+
"hostname %r doesn't match certificate subjectAltName %r" %
316+
(hostname, san_names))
317+
318+
common_names = _pyopenssl_cert_common_names(cert)
319+
for common_name in common_names:
320+
if (_ipaddress_match(common_name, hostname) if hostname_is_ip
321+
else _dnsname_match(common_name, hostname)):
322+
return
323+
324+
raise ssl.CertificateError(
325+
"hostname %r doesn't match certificate commonName %r" %
326+
(hostname, common_names))
327+
328+
133329
class EndPoint(object):
134330
"""
135331
Represents the information to connect to a cassandra node.
@@ -803,6 +999,7 @@ class Connection(object):
803999
endpoint = None
8041000
ssl_options = None
8051001
ssl_context = None
1002+
_ssl_options_explicit = False
8061003
last_error = None
8071004

8081005
# The current number of operations that are in flight. More precisely,
@@ -885,7 +1082,11 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
8851082
self.endpoint = host if isinstance(host, EndPoint) else DefaultEndPoint(host, port)
8861083

8871084
self.authenticator = authenticator
888-
self.ssl_options = ssl_options.copy() if ssl_options else {}
1085+
endpoint_ssl_options = self.endpoint.ssl_options
1086+
# Explicit ssl_options={} enables SSL with default options; omitted
1087+
# ssl_options=None leaves SSL disabled unless an endpoint supplies options.
1088+
self._ssl_options_explicit = ssl_options is not None
1089+
self.ssl_options = ssl_options.copy() if ssl_options is not None else {}
8891090
self.ssl_context = ssl_context
8901091
self.sockopts = sockopts
8911092
self.compression = compression
@@ -905,10 +1106,13 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
9051106
self._on_orphaned_stream_released = on_orphaned_stream_released
9061107
self._application_info = application_info
9071108

908-
if ssl_options:
909-
self.ssl_options.update(self.endpoint.ssl_options or {})
910-
elif self.endpoint.ssl_options:
911-
self.ssl_options = self.endpoint.ssl_options
1109+
if ssl_options is not None:
1110+
self.ssl_options.update(endpoint_ssl_options or {})
1111+
elif endpoint_ssl_options is not None:
1112+
self._ssl_options_explicit = True
1113+
self.ssl_options = endpoint_ssl_options
1114+
self._check_hostname = bool(self.ssl_options.get('check_hostname', False) or
1115+
getattr(self.ssl_context, 'check_hostname', False))
9121116

9131117
# PYTHON-1331
9141118
#
@@ -918,7 +1122,7 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
9181122
#
9191123
# Note the use of pop() here; we are very deliberately removing these params from ssl_options if they're present. After this
9201124
# operation ssl_options should contain only args needed for the ssl_context.wrap_socket() call.
921-
if not self.ssl_context and self.ssl_options:
1125+
if self.ssl_context is None and self._ssl_options_explicit:
9221126
self.ssl_context = self._build_ssl_context_from_options()
9231127

9241128
self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
@@ -942,6 +1146,10 @@ def host(self):
9421146
def port(self):
9431147
return self.endpoint.port
9441148

1149+
@property
1150+
def _ssl_enabled(self):
1151+
return self.ssl_context is not None or self._ssl_options_explicit
1152+
9451153
@classmethod
9461154
def initialize_reactor(cls):
9471155
"""
@@ -1000,10 +1208,18 @@ def _build_ssl_context_from_options(self):
10001208
# Python >= 3.10 requires either PROTOCOL_TLS_CLIENT or PROTOCOL_TLS_SERVER so we'll get ahead of things by always
10011209
# being explicit
10021210
ssl_version = opts.get('ssl_version', None) or ssl.PROTOCOL_TLS_CLIENT
1003-
cert_reqs = opts.get('cert_reqs', None) or ssl.CERT_REQUIRED
1211+
cert_reqs = opts.get('cert_reqs', None)
1212+
check_hostname = bool(opts.get('check_hostname', False))
1213+
if cert_reqs is None:
1214+
cert_reqs = (ssl.CERT_REQUIRED
1215+
if self.ssl_options
1216+
else ssl.CERT_NONE)
1217+
elif check_hostname and cert_reqs == ssl.CERT_NONE:
1218+
cert_reqs = ssl.CERT_REQUIRED
10041219
rv = ssl.SSLContext(protocol=int(ssl_version))
1005-
rv.check_hostname = bool(opts.get('check_hostname', False))
1006-
rv.options = int(cert_reqs)
1220+
rv.check_hostname = False
1221+
rv.verify_mode = cert_reqs
1222+
rv.check_hostname = check_hostname
10071223

10081224
certfile = opts.get('certfile', None)
10091225
keyfile = opts.get('keyfile', None)

cassandra/datastax/cloud/__init__.py

Lines changed: 5 additions & 4 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

@@ -181,12 +182,12 @@ def _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location):
181182
from OpenSSL import SSL
182183
except ImportError as e:
183184
raise ImportError(
184-
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops")\
185-
.with_traceback(e.__traceback__)
186-
ssl_context = SSL.Context(SSL.TLSv1_METHOD)
185+
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops"
186+
) from e
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

0 commit comments

Comments
 (0)