Skip to content

Commit 0f15bd9

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 0f15bd9

24 files changed

Lines changed: 2935 additions & 203 deletions

CHANGELOG.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ 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. Empty options without additional
14+
endpoint security settings encrypt the connection without verifying the
15+
server certificate. Cluster-level ``ssl_options=None`` does not enable TLS by
16+
itself; an ``ssl_context`` or endpoint SSL options still enable it. An
17+
endpoint ``server_hostname`` used only for SNI routing is not treated as an
18+
additional security setting for explicit empty cluster options.
19+
* Legacy ``ssl_options`` now work with the Twisted and Eventlet reactors by
20+
using pyOpenSSL contexts with mapped protocol, verification, cipher, SNI, and
21+
hostname-validation settings. Nonempty options without ``cert_reqs`` now
22+
default to peer-certificate verification on these reactors instead of
23+
pyOpenSSL's ``VERIFY_NONE``. When ``ca_certs`` is omitted, the system trust
24+
roots are loaded. Serialized symbolic stdlib protocol names are supported;
25+
plain integer protocol values are mapped only when unambiguous and otherwise
26+
rejected rather than being silently interpreted as a different pyOpenSSL
27+
method. Existing configurations that connect to an untrusted certificate
28+
must supply the appropriate CA or explicitly opt out with
29+
``cert_reqs=ssl.CERT_NONE`` and leave ``check_hostname`` disabled.
30+
* Hostname verification is now performed for Twisted and Eventlet connections
31+
when ``check_hostname`` is enabled. The flag was previously not propagated
32+
to the connection, so hostname mismatches were silently accepted; such
33+
mismatches now fail the connection.
34+
* ``SSLContext`` setup now applies ``cert_reqs`` through ``verify_mode`` instead
35+
of overwriting the SSL options bitmask, preserving default hardening flags.
36+
1037
Others
1138
------
1239
* ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are

cassandra/cluster.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import re
3838
import queue
3939
import socket
40+
import ssl
4041
import sys
4142
import time
4243
from threading import Lock, RLock, Thread, Event
@@ -883,12 +884,29 @@ def default_retry_policy(self, policy):
883884
when new sockets are created. This should be used when client encryption is enabled
884885
in Cassandra.
885886
886-
The following documentation only applies when ssl_options is used without ssl_context.
887-
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.
887+
The following documentation only applies when cluster-level ``ssl_options``
888+
is used without ``ssl_context``. An :class:`~cassandra.connection.EndPoint`
889+
may independently supply SSL options for its connections.
890+
891+
.. versionchanged:: 3.29.12
892+
893+
An explicit empty dict (``ssl_options={}``) enables TLS with default
894+
options. Cluster-level ``ssl_options=None`` does not enable TLS by
895+
itself; an ``ssl_context`` or SSL options supplied by the selected
896+
:class:`~cassandra.connection.EndPoint` still enable it. Empty options
897+
do not verify the server certificate unless the endpoint supplies
898+
additional security settings. A nonempty options dict enables
899+
peer-certificate verification by default unless ``cert_reqs`` explicitly
900+
disables it. Setting
901+
``'check_hostname': True`` always enables peer-certificate verification
902+
because hostname checking requires an authenticated certificate chain.
903+
When verification is enabled and ``ca_certs`` is omitted, the system
904+
trust roots are loaded.
905+
906+
Supply ``ca_certs`` as a path to a CA certificate file when the server uses
907+
a private CA that is not in the system trust store. You probably also want
908+
to specify ``ssl_version`` as ``ssl.PROTOCOL_TLS`` to match Cassandra's
909+
default protocol.
892910
893911
.. versionchanged:: 3.3.0
894912
@@ -1298,7 +1316,8 @@ def __init__(self,
12981316

12991317
if cloud is not None:
13001318
self.cloud = cloud
1301-
if contact_points is not _NOT_SET or endpoint_factory or ssl_context or ssl_options:
1319+
if (contact_points is not _NOT_SET or endpoint_factory or
1320+
ssl_context is not None or ssl_options is not None):
13021321
raise ValueError("contact_points, endpoint_factory, ssl_context, and ssl_options "
13031322
"cannot be specified with a cloud configuration")
13041323

@@ -1508,12 +1527,25 @@ def __init__(self,
15081527

15091528
self.metrics_enabled = metrics_enabled
15101529

1511-
if ssl_options and not ssl_context:
1530+
if ssl_options is not None and ssl_context is None:
15121531
warn('Using ssl_options without ssl_context is '
15131532
'deprecated and will result in an error in '
15141533
'the next major release. Please use ssl_context '
15151534
'to prepare for that release.',
15161535
DeprecationWarning)
1536+
if not ssl_options:
1537+
log.warning(
1538+
'Cluster-level ssl_options enable TLS without requesting '
1539+
'server certificate verification; endpoint-level SSL '
1540+
'options may enable verification for individual '
1541+
'connections')
1542+
elif (ssl_options.get('check_hostname', False) and
1543+
ssl_options.get('cert_reqs') == ssl.CERT_NONE):
1544+
log.warning(
1545+
'Cluster-level check_hostname=True requires server '
1546+
'certificate verification and overrides '
1547+
'cert_reqs=CERT_NONE at cluster level; endpoint-level SSL '
1548+
'options may override these settings')
15171549

15181550
self.ssl_options = ssl_options
15191551
self.ssl_context = ssl_context

cassandra/connection.py

Lines changed: 70 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
import io
2121
import logging
2222
import socket
23+
import ssl
2324
import struct
2425
import sys
2526
from threading import Thread, Event, RLock, Condition
2627
import time
27-
import ssl
2828
import uuid
2929
import weakref
3030
import random
@@ -50,6 +50,10 @@
5050
AuthSuccessMessage, ProtocolException,
5151
RegisterMessage, ReviseRequestMessage)
5252
from cassandra.segment import SegmentCodec, CrcException
53+
from cassandra.tls import (_build_ssl_context_from_options,
54+
_prepare_ssl_options,
55+
_ssl_options_requiring_new_context,
56+
_wrap_socket_from_context)
5357
from cassandra.util import OrderedDict
5458
from cassandra.shard_info import ShardingInfo # noqa: F401 # re-exported for cassandra.connection.ShardingInfo
5559

@@ -803,6 +807,8 @@ class Connection(object):
803807
endpoint = None
804808
ssl_options = None
805809
ssl_context = None
810+
_ssl_options_explicit = False
811+
_ssl_options_verify_by_default = None
806812
last_error = None
807813

808814
# The current number of operations that are in flight. More precisely,
@@ -858,6 +864,7 @@ class Connection(object):
858864
_socket = None
859865

860866
_socket_impl = socket
867+
_connect_socket_error_types = (socket.error,)
861868

862869
_check_hostname = False
863870
_product_type = None
@@ -883,9 +890,21 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
883890
on_orphaned_stream_released=None, application_info: Optional[ApplicationInfoBase] = None):
884891
# TODO next major rename host to endpoint and remove port kwarg.
885892
self.endpoint = host if isinstance(host, EndPoint) else DefaultEndPoint(host, port)
893+
endpoint_ssl_options = self.endpoint.ssl_options
894+
self._endpoint_ssl_options = (
895+
dict(endpoint_ssl_options)
896+
if endpoint_ssl_options is not None
897+
else None
898+
)
886899

887900
self.authenticator = authenticator
888-
self.ssl_options = ssl_options.copy() if ssl_options else {}
901+
(
902+
self.ssl_options,
903+
self._ssl_options_explicit,
904+
self._ssl_options_verify_by_default,
905+
) = _prepare_ssl_options(
906+
ssl_options, self._endpoint_ssl_options
907+
)
889908
self.ssl_context = ssl_context
890909
self.sockopts = sockopts
891910
self.compression = compression
@@ -905,21 +924,28 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
905924
self._on_orphaned_stream_released = on_orphaned_stream_released
906925
self._application_info = application_info
907926

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
912-
913-
# PYTHON-1331
914-
#
915-
# We always use SSLContext.wrap_socket() now but legacy configs may have other params that were passed to ssl.wrap_socket()...
916-
# and either could have 'check_hostname'. Remove these params into a separate map and use them to build an SSLContext if
917-
# we need to do so.
918-
#
919-
# Note the use of pop() here; we are very deliberately removing these params from ssl_options if they're present. After this
920-
# 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:
927+
# An explicitly empty options mapping enables TLS with default options.
928+
if self.ssl_context is None and self._ssl_options_explicit:
922929
self.ssl_context = self._build_ssl_context_from_options()
930+
elif self.ssl_context is not None:
931+
endpoint_context_options = _ssl_options_requiring_new_context(
932+
self._endpoint_ssl_options)
933+
if endpoint_context_options:
934+
# SSLContext has no supported copy operation, and settings such
935+
# as trust roots, client keys, and pyOpenSSL callbacks cannot be
936+
# restored reliably. The cluster context is shared by concurrent
937+
# connections, so mutating and restoring it would leak endpoint
938+
# policy or race with another handshake. Require the existing
939+
# context to remain immutable; callers needing endpoint-specific
940+
# context settings can omit ssl_context and use ssl_options,
941+
# which builds an independent context for every connection.
942+
raise ValueError(
943+
"Endpoint SSL options %s require an independent SSL "
944+
"context and cannot be combined with a supplied "
945+
"ssl_context" % sorted(endpoint_context_options))
946+
947+
self._check_hostname = bool(self.ssl_options.get('check_hostname', False) or
948+
getattr(self.ssl_context, 'check_hostname', False))
923949

924950
self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
925951
# Don't fill the deque with 2**15 items right away. Start with some and add
@@ -942,6 +968,10 @@ def host(self):
942968
def port(self):
943969
return self.endpoint.port
944970

971+
@property
972+
def _ssl_enabled(self):
973+
return self.ssl_context is not None or self._ssl_options_explicit
974+
945975
@classmethod
946976
def initialize_reactor(cls):
947977
"""
@@ -992,47 +1022,16 @@ def factory(cls, endpoint, timeout, host_conn = None, *args, **kwargs):
9921022
return conn
9931023

9941024
def _build_ssl_context_from_options(self):
995-
996-
# Extract a subset of names from self.ssl_options which apply to SSLContext creation
997-
ssl_context_opt_names = ['ssl_version', 'cert_reqs', 'check_hostname', 'keyfile', 'certfile', 'ca_certs', 'ciphers']
998-
opts = {k:self.ssl_options.get(k, None) for k in ssl_context_opt_names if k in self.ssl_options}
999-
1000-
# Python >= 3.10 requires either PROTOCOL_TLS_CLIENT or PROTOCOL_TLS_SERVER so we'll get ahead of things by always
1001-
# being explicit
1002-
ssl_version = opts.get('ssl_version', None) or ssl.PROTOCOL_TLS_CLIENT
1003-
cert_reqs = opts.get('cert_reqs', None) or ssl.CERT_REQUIRED
1004-
rv = ssl.SSLContext(protocol=int(ssl_version))
1005-
rv.check_hostname = bool(opts.get('check_hostname', False))
1006-
rv.options = int(cert_reqs)
1007-
1008-
certfile = opts.get('certfile', None)
1009-
keyfile = opts.get('keyfile', None)
1010-
if certfile:
1011-
rv.load_cert_chain(certfile, keyfile)
1012-
ca_certs = opts.get('ca_certs', None)
1013-
if ca_certs:
1014-
rv.load_verify_locations(ca_certs)
1015-
ciphers = opts.get('ciphers', None)
1016-
if ciphers:
1017-
rv.set_ciphers(ciphers)
1018-
1019-
return rv
1025+
return _build_ssl_context_from_options(
1026+
self.ssl_options,
1027+
verify_by_default=self._ssl_options_verify_by_default)
10201028

10211029
def _wrap_socket_from_context(self):
1022-
1023-
# Extract a subset of names from self.ssl_options which apply to SSLContext.wrap_socket (or at least the parts
1024-
# of it that don't involve building an SSLContext under the covers)
1025-
wrap_socket_opt_names = ['server_side', 'do_handshake_on_connect', 'suppress_ragged_eofs', 'server_hostname']
1026-
opts = {k:self.ssl_options.get(k, None) for k in wrap_socket_opt_names if k in self.ssl_options}
1027-
1028-
# PYTHON-1186: set the server_hostname only if the SSLContext has
1029-
# check_hostname enabled and it is not already provided by the EndPoint ssl options
1030-
#opts['server_hostname'] = self.endpoint.address
1031-
if (self.ssl_context.check_hostname and 'server_hostname' not in opts):
1032-
server_hostname = self.endpoint.address
1033-
opts['server_hostname'] = server_hostname
1034-
1035-
return self.ssl_context.wrap_socket(self._socket, **opts)
1030+
return _wrap_socket_from_context(
1031+
self.ssl_context,
1032+
self._socket,
1033+
self.ssl_options,
1034+
self.endpoint.address)
10361035

10371036
def _initiate_connection(self, sockaddr):
10381037
if self.features.shard_id is not None:
@@ -1089,13 +1088,27 @@ def _connect_socket(self):
10891088
self._validate_hostname()
10901089
sockerr = None
10911090
break
1092-
except socket.error as err:
1091+
except self._connect_socket_error_types as err:
1092+
if self._socket:
1093+
self._socket.close()
1094+
self._socket = None
1095+
# Preserve a TLS or driver error over later socket failures so
1096+
# a final ECONNREFUSED cannot hide certificate diagnostics.
1097+
if (sockerr is None or
1098+
(isinstance(sockerr, socket.error) and
1099+
not isinstance(sockerr, ssl.SSLError))):
1100+
sockerr = err
1101+
except Exception:
10931102
if self._socket:
10941103
self._socket.close()
10951104
self._socket = None
1096-
sockerr = err
1105+
raise
10971106

10981107
if sockerr:
1108+
if isinstance(sockerr, ssl.SSLError):
1109+
raise sockerr
1110+
if not isinstance(sockerr, socket.error):
1111+
raise sockerr
10991112
raise socket.error(sockerr.errno, "Tried connecting to %s. Last error: %s" %
11001113
([a[4] for a in addresses], sockerr.strerror or sockerr))
11011114

cassandra/datastax/cloud/__init__.py

Lines changed: 12 additions & 9 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.tls import _build_pyopenssl_context_from_options
3738

3839
log = logging.getLogger(__name__)
3940

@@ -181,12 +182,14 @@ 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)
187-
ssl_context.set_verify(SSL.VERIFY_PEER, callback=lambda _1, _2, _3, _4, ok: ok)
188-
ssl_context.use_certificate_file(cert_location)
189-
ssl_context.use_privatekey_file(key_location)
190-
ssl_context.load_verify_locations(ca_cert_location)
191-
192-
return ssl_context
185+
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops"
186+
) from e
187+
return _build_pyopenssl_context_from_options(
188+
SSL,
189+
{
190+
'ca_certs': ca_cert_location,
191+
'certfile': cert_location,
192+
'keyfile': key_location,
193+
'cert_reqs': CERT_REQUIRED,
194+
}
195+
)

0 commit comments

Comments
 (0)