Skip to content

Commit 81d1946

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 b8b714c commit 81d1946

20 files changed

Lines changed: 1435 additions & 113 deletions

CHANGELOG.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ Features
77
statements skip re-sending result metadata on EXECUTE, and the driver automatically
88
refreshes cached metadata when the server detects a schema change (DRIVER-153)
99

10+
Bug Fixes
11+
---------
12+
* Explicit ``ssl_options={}`` and endpoint SSL options now enable TLS instead
13+
of being treated as omitted SSL options. An empty dict encrypts the
14+
connection without verifying the server certificate; ``ssl_options=None``
15+
remains plaintext.
16+
* Legacy ``ssl_options`` now work with the Twisted and Eventlet reactors by
17+
using pyOpenSSL contexts with mapped protocol, verification, cipher, SNI, and
18+
hostname-validation settings. Nonempty options without ``cert_reqs`` now
19+
default to peer-certificate verification on these reactors instead of
20+
pyOpenSSL's ``VERIFY_NONE``. When ``ca_certs`` is omitted, the system trust
21+
roots are loaded. Existing configurations that connect to an untrusted
22+
certificate must supply the appropriate CA or explicitly opt out with
23+
``cert_reqs=ssl.CERT_NONE`` and leave ``check_hostname`` disabled.
24+
* Hostname verification is now performed for Twisted and Eventlet connections
25+
when ``check_hostname`` is enabled. The flag was previously not propagated
26+
to the connection, so hostname mismatches were silently accepted; such
27+
mismatches now fail the connection.
28+
* ``SSLContext`` setup now applies ``cert_reqs`` through ``verify_mode`` instead
29+
of overwriting the SSL options bitmask, preserving default hardening flags.
30+
1031
Others
1132
------
1233
* ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are

cassandra/cluster.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -885,10 +885,22 @@ def default_retry_policy(self, policy):
885885
886886
The following documentation only applies when ssl_options is used without ssl_context.
887887
888-
By default, a ``ca_certs`` value should be supplied (the value should be
889-
a string pointing to the location of the CA certs file), and you probably
890-
want to specify ``ssl_version`` as ``ssl.PROTOCOL_TLS`` to match
891-
Cassandra's default protocol.
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 verify the server certificate. A nonempty options
893+
dict enables peer-certificate verification by default unless
894+
``cert_reqs`` explicitly disables it. Setting
895+
``'check_hostname': True`` always enables peer-certificate verification
896+
because hostname checking requires an authenticated certificate chain.
897+
When verification is enabled and ``ca_certs`` is omitted, the system
898+
trust roots are loaded.
899+
900+
Supply ``ca_certs`` as a path to a CA certificate file when the server uses
901+
a private CA that is not in the system trust store. You probably also want
902+
to specify ``ssl_version`` as ``ssl.PROTOCOL_TLS`` to match Cassandra's
903+
default protocol.
892904
893905
.. versionchanged:: 3.3.0
894906
@@ -1298,7 +1310,8 @@ def __init__(self,
12981310

12991311
if cloud is not None:
13001312
self.cloud = cloud
1301-
if contact_points is not _NOT_SET or endpoint_factory or ssl_context or ssl_options:
1313+
if (contact_points is not _NOT_SET or endpoint_factory or
1314+
ssl_context is not None or ssl_options is not None):
13021315
raise ValueError("contact_points, endpoint_factory, ssl_context, and ssl_options "
13031316
"cannot be specified with a cloud configuration")
13041317

@@ -1508,7 +1521,7 @@ def __init__(self,
15081521

15091522
self.metrics_enabled = metrics_enabled
15101523

1511-
if ssl_options and not ssl_context:
1524+
if ssl_options is not None and ssl_context is None:
15121525
warn('Using ssl_options without ssl_context is '
15131526
'deprecated and will result in an error in '
15141527
'the next major release. Please use ssl_context '

cassandra/connection.py

Lines changed: 248 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,220 @@ 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+
"""
180+
Make hostname verification fail closed for a caller-supplied context.
181+
182+
When hostname checking is enabled, this may mutate the context's verify
183+
mode and callback because pyOpenSSL does not expose a non-mutating way to
184+
require peer verification.
185+
"""
186+
if not check_hostname:
187+
return
188+
189+
get_verify_mode = getattr(context, 'get_verify_mode', None)
190+
verify_mode = get_verify_mode() if get_verify_mode is not None else None
191+
if (verify_mode is not None and
192+
verify_mode & ssl_module.VERIFY_PEER):
193+
return
194+
195+
log.warning(
196+
"check_hostname=True requires peer verification; mutating supplied "
197+
"pyOpenSSL context to use VERIFY_PEER and replacing its verification "
198+
"callback"
199+
)
200+
context.set_verify(
201+
ssl_module.VERIFY_PEER,
202+
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
203+
)
204+
205+
206+
def _build_pyopenssl_context_from_options(ssl_module, ssl_options):
207+
ssl_options = ssl_options or {}
208+
context = ssl_module.Context(
209+
_pyopenssl_ssl_method_from_stdlib(ssl_module, ssl_options.get('ssl_version', None))
210+
)
211+
if 'certfile' in ssl_options:
212+
context.use_certificate_file(ssl_options['certfile'])
213+
if 'keyfile' in ssl_options:
214+
context.use_privatekey_file(ssl_options['keyfile'])
215+
if 'ca_certs' in ssl_options:
216+
context.load_verify_locations(ssl_options['ca_certs'])
217+
cert_reqs = _pyopenssl_verify_mode_from_cert_reqs(
218+
ssl_module, ssl_options.get('cert_reqs', None))
219+
if cert_reqs is None:
220+
cert_reqs = (ssl_module.VERIFY_PEER
221+
if ssl_options
222+
else ssl_module.VERIFY_NONE)
223+
elif ssl_options.get('check_hostname', False) and cert_reqs == ssl_module.VERIFY_NONE:
224+
cert_reqs = ssl_module.VERIFY_PEER
225+
context.set_verify(
226+
cert_reqs,
227+
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
228+
)
229+
if (cert_reqs & ssl_module.VERIFY_PEER and
230+
not ssl_options.get('ca_certs')):
231+
set_default_verify_paths = getattr(context, 'set_default_verify_paths', None)
232+
if set_default_verify_paths is not None:
233+
set_default_verify_paths()
234+
ciphers = ssl_options.get('ciphers', None)
235+
if ciphers:
236+
if isinstance(ciphers, str):
237+
ciphers = ciphers.encode('ascii')
238+
context.set_cipher_list(ciphers)
239+
return context
240+
241+
242+
def _normalized_hostname(hostname):
243+
return hostname.rstrip('.').lower()
244+
245+
246+
def _dnsname_match(dn, hostname):
247+
dn = _normalized_hostname(dn)
248+
hostname = _normalized_hostname(hostname)
249+
250+
if not dn or not hostname:
251+
return False
252+
253+
if '*' not in dn:
254+
return dn == hostname
255+
256+
dn_labels = dn.split('.')
257+
hostname_labels = hostname.split('.')
258+
if (len(dn_labels) != len(hostname_labels) or
259+
len(dn_labels) < 2 or dn_labels[0] != '*' or
260+
not hostname_labels[0] or
261+
any('*' in label for label in dn_labels[1:])):
262+
return False
263+
264+
return dn_labels[1:] == hostname_labels[1:]
265+
266+
267+
def _ipaddress_match(cert_ip, hostname):
268+
try:
269+
host_ip = ipaddress.ip_address(hostname)
270+
cert_ip = ipaddress.ip_address(cert_ip)
271+
except ValueError:
272+
return False
273+
return cert_ip == host_ip
274+
275+
276+
def _is_ip_address(hostname):
277+
try:
278+
ipaddress.ip_address(hostname)
279+
except ValueError:
280+
return False
281+
return True
282+
283+
284+
def _pyopenssl_cert_subject_alt_names(cert):
285+
# Imported lazily because cryptography is an optional driver dependency.
286+
# pyOpenSSL supplies it whenever this code path is used.
287+
from cryptography import x509
288+
289+
try:
290+
san = cert.to_cryptography().extensions.get_extension_for_class(
291+
x509.SubjectAlternativeName).value
292+
except x509.ExtensionNotFound:
293+
return [], []
294+
295+
return (
296+
san.get_values_for_type(x509.DNSName),
297+
[str(ip) for ip in san.get_values_for_type(x509.IPAddress)]
298+
)
299+
300+
301+
def _pyopenssl_cert_common_names(cert):
302+
# Imported lazily for the same reason as the SAN types above.
303+
from cryptography.x509.oid import NameOID
304+
305+
return [
306+
attribute.value
307+
for attribute in cert.to_cryptography().subject.get_attributes_for_oid(
308+
NameOID.COMMON_NAME)
309+
]
310+
311+
312+
def _validate_pyopenssl_hostname(cert, hostname):
313+
if cert is None:
314+
raise ssl.CertificateError(
315+
"peer did not present a certificate; cannot verify hostname %r" % (hostname,))
316+
317+
san_dns_names, san_ip_addresses = _pyopenssl_cert_subject_alt_names(cert)
318+
san_names = san_dns_names + san_ip_addresses
319+
hostname_is_ip = _is_ip_address(hostname)
320+
321+
for cert_ip in san_ip_addresses:
322+
if _ipaddress_match(cert_ip, hostname):
323+
return
324+
if hostname_is_ip and san_names:
325+
raise ssl.CertificateError(
326+
"hostname %r doesn't match certificate subjectAltName %r" %
327+
(hostname, san_names))
328+
329+
for cert_hostname in san_dns_names:
330+
if _dnsname_match(cert_hostname, hostname):
331+
return
332+
if san_names:
333+
raise ssl.CertificateError(
334+
"hostname %r doesn't match certificate subjectAltName %r" %
335+
(hostname, san_names))
336+
337+
common_names = _pyopenssl_cert_common_names(cert)
338+
for common_name in common_names:
339+
if (_ipaddress_match(common_name, hostname) if hostname_is_ip
340+
else _dnsname_match(common_name, hostname)):
341+
return
342+
343+
raise ssl.CertificateError(
344+
"hostname %r doesn't match certificate commonName %r" %
345+
(hostname, common_names))
346+
347+
133348
class EndPoint(object):
134349
"""
135350
Represents the information to connect to a cassandra node.
@@ -803,6 +1018,7 @@ class Connection(object):
8031018
endpoint = None
8041019
ssl_options = None
8051020
ssl_context = None
1021+
_ssl_options_explicit = False
8061022
last_error = None
8071023

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

8871103
self.authenticator = authenticator
888-
self.ssl_options = ssl_options.copy() if ssl_options else {}
1104+
endpoint_ssl_options = self.endpoint.ssl_options
1105+
# Explicit ssl_options={} enables SSL with default options; omitted
1106+
# ssl_options=None leaves SSL disabled unless an endpoint supplies options.
1107+
self._ssl_options_explicit = ssl_options is not None
1108+
self.ssl_options = ssl_options.copy() if ssl_options is not None else {}
8891109
self.ssl_context = ssl_context
8901110
self.sockopts = sockopts
8911111
self.compression = compression
@@ -905,10 +1125,13 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
9051125
self._on_orphaned_stream_released = on_orphaned_stream_released
9061126
self._application_info = application_info
9071127

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
1128+
if ssl_options is not None:
1129+
self.ssl_options.update(endpoint_ssl_options or {})
1130+
elif endpoint_ssl_options is not None:
1131+
self._ssl_options_explicit = True
1132+
self.ssl_options = endpoint_ssl_options
1133+
self._check_hostname = bool(self.ssl_options.get('check_hostname', False) or
1134+
getattr(self.ssl_context, 'check_hostname', False))
9121135

9131136
# PYTHON-1331
9141137
#
@@ -918,7 +1141,7 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
9181141
#
9191142
# Note the use of pop() here; we are very deliberately removing these params from ssl_options if they're present. After this
9201143
# 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:
1144+
if self.ssl_context is None and self._ssl_options_explicit:
9221145
self.ssl_context = self._build_ssl_context_from_options()
9231146

9241147
self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
@@ -942,6 +1165,10 @@ def host(self):
9421165
def port(self):
9431166
return self.endpoint.port
9441167

1168+
@property
1169+
def _ssl_enabled(self):
1170+
return self.ssl_context is not None or self._ssl_options_explicit
1171+
9451172
@classmethod
9461173
def initialize_reactor(cls):
9471174
"""
@@ -1000,10 +1227,18 @@ def _build_ssl_context_from_options(self):
10001227
# Python >= 3.10 requires either PROTOCOL_TLS_CLIENT or PROTOCOL_TLS_SERVER so we'll get ahead of things by always
10011228
# being explicit
10021229
ssl_version = opts.get('ssl_version', None) or ssl.PROTOCOL_TLS_CLIENT
1003-
cert_reqs = opts.get('cert_reqs', None) or ssl.CERT_REQUIRED
1230+
cert_reqs = opts.get('cert_reqs', None)
1231+
check_hostname = bool(opts.get('check_hostname', False))
1232+
if cert_reqs is None:
1233+
cert_reqs = (ssl.CERT_REQUIRED
1234+
if self.ssl_options
1235+
else ssl.CERT_NONE)
1236+
elif check_hostname and cert_reqs == ssl.CERT_NONE:
1237+
cert_reqs = ssl.CERT_REQUIRED
10041238
rv = ssl.SSLContext(protocol=int(ssl_version))
1005-
rv.check_hostname = bool(opts.get('check_hostname', False))
1006-
rv.options = int(cert_reqs)
1239+
rv.check_hostname = False
1240+
rv.verify_mode = cert_reqs
1241+
rv.check_hostname = check_hostname
10071242

10081243
certfile = opts.get('certfile', None)
10091244
keyfile = opts.get('keyfile', None)
@@ -1012,6 +1247,10 @@ def _build_ssl_context_from_options(self):
10121247
ca_certs = opts.get('ca_certs', None)
10131248
if ca_certs:
10141249
rv.load_verify_locations(ca_certs)
1250+
elif cert_reqs != ssl.CERT_NONE:
1251+
load_default_certs = getattr(rv, 'load_default_certs', None)
1252+
if load_default_certs is not None:
1253+
load_default_certs()
10151254
ciphers = opts.get('ciphers', None)
10161255
if ciphers:
10171256
rv.set_ciphers(ciphers)

0 commit comments

Comments
 (0)