Skip to content

Commit 805b678

Browse files
committed
connection: preserve explicit empty ssl_options
Problem: several SSL paths used ssl_options truthiness, so explicit ssl_options={} was treated like omitted ssl_options=None. That disabled SSL setup in connection/reactor paths, skipped the ssl_options deprecation warning, and made Insights report SSL as disabled. Fix: preserve whether ssl_options was supplied and expose a shared SSL-enabled predicate. Build default SSL contexts for explicit ssl_options on the standard path, use pyOpenSSL contexts for Twisted/Eventlet, and update Cluster and Insights checks to use explicit None comparisons. Add focused unit coverage for connection setup, client routes, Twisted/Eventlet, and Insights reporting.
1 parent e605de2 commit 805b678

13 files changed

Lines changed: 653 additions & 72 deletions

cassandra/cluster.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,8 @@ def __init__(self,
12981298

12991299
if cloud is not None:
13001300
self.cloud = cloud
1301-
if contact_points is not _NOT_SET or endpoint_factory or ssl_context or ssl_options:
1301+
if (contact_points is not _NOT_SET or endpoint_factory or
1302+
ssl_context is not None or ssl_options is not None):
13021303
raise ValueError("contact_points, endpoint_factory, ssl_context, and ssl_options "
13031304
"cannot be specified with a cloud configuration")
13041305

@@ -1508,7 +1509,7 @@ def __init__(self,
15081509

15091510
self.metrics_enabled = metrics_enabled
15101511

1511-
if ssl_options and not ssl_context:
1512+
if ssl_options is not None and ssl_context is None:
15121513
warn('Using ssl_options without ssl_context is '
15131514
'deprecated and will result in an error in '
15141515
'the next major release. Please use ssl_context '

cassandra/connection.py

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from functools import wraps, partial, total_ordering
1919
from heapq import heappush, heappop
2020
import io
21+
import ipaddress
2122
import logging
2223
import socket
2324
import struct
@@ -55,6 +56,104 @@
5556

5657
log = logging.getLogger(__name__)
5758

59+
60+
def _pyopenssl_cert_to_ssl_cert(peer_cert):
61+
from cryptography import x509
62+
from cryptography.x509.oid import NameOID
63+
64+
cert = peer_cert.to_cryptography()
65+
ssl_cert = {}
66+
67+
subject = tuple((('commonName', attr.value),)
68+
for attr in cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME))
69+
if subject:
70+
ssl_cert['subject'] = subject
71+
72+
subject_alt_names = []
73+
try:
74+
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
75+
except x509.ExtensionNotFound:
76+
pass
77+
else:
78+
subject_alt_names.extend(('DNS', name) for name in san.get_values_for_type(x509.DNSName))
79+
subject_alt_names.extend(('IP Address', str(address))
80+
for address in san.get_values_for_type(x509.IPAddress))
81+
82+
if subject_alt_names:
83+
ssl_cert['subjectAltName'] = tuple(subject_alt_names)
84+
85+
return ssl_cert
86+
87+
88+
def _validate_pyopenssl_hostname(peer_cert, hostname):
89+
cert = _pyopenssl_cert_to_ssl_cert(peer_cert)
90+
try:
91+
host_ip = ipaddress.ip_address(hostname)
92+
except ValueError:
93+
host_ip = None
94+
95+
names = []
96+
for key, value in cert.get('subjectAltName', ()):
97+
if key == 'DNS':
98+
if host_ip is None and _dnsname_match(value, hostname):
99+
return
100+
names.append(value)
101+
elif key == 'IP Address':
102+
if host_ip is not None and _ipaddress_match(value, host_ip):
103+
return
104+
names.append(value)
105+
106+
if not names:
107+
for subject in cert.get('subject', ()):
108+
for key, value in subject:
109+
if key == 'commonName':
110+
if _dnsname_match(value, hostname):
111+
return
112+
names.append(value)
113+
114+
if len(names) > 1:
115+
raise ssl.CertificateError("hostname {!r} doesn't match either of {}".format(
116+
hostname, ', '.join(map(repr, names))))
117+
if len(names) == 1:
118+
raise ssl.CertificateError("hostname {!r} doesn't match {!r}".format(hostname, names[0]))
119+
raise ssl.CertificateError("no appropriate commonName or subjectAltName fields were found")
120+
121+
122+
def _dnsname_match(pattern, hostname):
123+
if not pattern:
124+
return False
125+
126+
wildcards = pattern.count('*')
127+
if not wildcards:
128+
return pattern.lower() == hostname.lower()
129+
if wildcards > 1:
130+
raise ssl.CertificateError(
131+
"too many wildcards in certificate DNS name: {!r}.".format(pattern))
132+
133+
leftmost, sep, remainder = pattern.partition('.')
134+
if '*' in remainder:
135+
raise ssl.CertificateError(
136+
"wildcard can only be present in the leftmost label: {!r}.".format(pattern))
137+
if not sep:
138+
raise ssl.CertificateError(
139+
"sole wildcard without additional labels is not supported: {!r}.".format(pattern))
140+
if leftmost != '*':
141+
raise ssl.CertificateError(
142+
"partial wildcards in leftmost label are not supported: {!r}.".format(pattern))
143+
144+
hostname_leftmost, sep, hostname_remainder = hostname.partition('.')
145+
if not hostname_leftmost or not sep:
146+
return False
147+
return remainder.lower() == hostname_remainder.lower()
148+
149+
150+
def _ipaddress_match(pattern, host_ip):
151+
try:
152+
return ipaddress.ip_address(pattern) == host_ip
153+
except ValueError:
154+
return False
155+
156+
58157
segment_codec_no_compression = SegmentCodec()
59158
segment_codec_lz4 = None
60159

@@ -803,6 +902,7 @@ class Connection(object):
803902
endpoint = None
804903
ssl_options = None
805904
ssl_context = None
905+
_ssl_options_explicit = False
806906
last_error = None
807907

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

887987
self.authenticator = authenticator
888-
self.ssl_options = ssl_options.copy() if ssl_options else {}
988+
endpoint_ssl_options = self.endpoint.ssl_options
989+
# Explicit ssl_options={} enables SSL with default options; omitted
990+
# ssl_options=None leaves SSL disabled unless an endpoint supplies options.
991+
self._ssl_options_explicit = ssl_options is not None
992+
self.ssl_options = ssl_options.copy() if ssl_options is not None else {}
889993
self.ssl_context = ssl_context
890994
self.sockopts = sockopts
891995
self.compression = compression
@@ -905,10 +1009,13 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
9051009
self._on_orphaned_stream_released = on_orphaned_stream_released
9061010
self._application_info = application_info
9071011

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
1012+
if ssl_options is not None:
1013+
self.ssl_options.update(endpoint_ssl_options or {})
1014+
elif endpoint_ssl_options is not None:
1015+
self._ssl_options_explicit = True
1016+
self.ssl_options = endpoint_ssl_options
1017+
self._check_hostname = bool(self.ssl_options.get('check_hostname', False) or
1018+
getattr(self.ssl_context, 'check_hostname', False))
9121019

9131020
# PYTHON-1331
9141021
#
@@ -918,7 +1025,7 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
9181025
#
9191026
# Note the use of pop() here; we are very deliberately removing these params from ssl_options if they're present. After this
9201027
# 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:
1028+
if self.ssl_context is None and self._ssl_options_explicit:
9221029
self.ssl_context = self._build_ssl_context_from_options()
9231030

9241031
self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
@@ -942,6 +1049,10 @@ def host(self):
9421049
def port(self):
9431050
return self.endpoint.port
9441051

1052+
@property
1053+
def _ssl_enabled(self):
1054+
return self.ssl_context is not None or self._ssl_options_explicit
1055+
9451056
@classmethod
9461057
def initialize_reactor(cls):
9471058
"""
@@ -1000,10 +1111,15 @@ def _build_ssl_context_from_options(self):
10001111
# Python >= 3.10 requires either PROTOCOL_TLS_CLIENT or PROTOCOL_TLS_SERVER so we'll get ahead of things by always
10011112
# being explicit
10021113
ssl_version = opts.get('ssl_version', None) or ssl.PROTOCOL_TLS_CLIENT
1003-
cert_reqs = opts.get('cert_reqs', None) or ssl.CERT_REQUIRED
1114+
cert_reqs = opts.get('cert_reqs', None)
1115+
if cert_reqs is None:
1116+
cert_reqs = (ssl.CERT_REQUIRED
1117+
if (opts.get('ca_certs', None) or opts.get('check_hostname', False))
1118+
else ssl.CERT_NONE)
10041119
rv = ssl.SSLContext(protocol=int(ssl_version))
1120+
rv.check_hostname = False
1121+
rv.verify_mode = cert_reqs
10051122
rv.check_hostname = bool(opts.get('check_hostname', False))
1006-
rv.options = int(cert_reqs)
10071123

10081124
certfile = opts.get('certfile', None)
10091125
keyfile = opts.get('keyfile', None)

cassandra/datastax/insights/reporter.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
log = logging.getLogger(__name__)
3434

3535

36+
def _ssl_options_cert_validation_enabled(ssl_options):
37+
cert_reqs = ssl_options.get('cert_reqs')
38+
if cert_reqs is not None:
39+
return cert_reqs != ssl.CERT_NONE
40+
return bool(ssl_options.get('ca_certs') or ssl_options.get('check_hostname', False))
41+
42+
3643
class MonitorReporter(Thread):
3744

3845
def __init__(self, interval_sec, session):
@@ -141,14 +148,14 @@ def _get_startup_data(self):
141148

142149
cert_validation = None
143150
try:
144-
if self._session.cluster.ssl_context:
151+
if self._session.cluster.ssl_context is not None:
145152
if isinstance(self._session.cluster.ssl_context, ssl.SSLContext):
146153
cert_validation = self._session.cluster.ssl_context.verify_mode == ssl.CERT_REQUIRED
147154
else: # pyopenssl
148155
from OpenSSL import SSL
149156
cert_validation = self._session.cluster.ssl_context.get_verify_mode() != SSL.VERIFY_NONE
150-
elif self._session.cluster.ssl_options:
151-
cert_validation = self._session.cluster.ssl_options.get('cert_reqs') == ssl.CERT_REQUIRED
157+
elif self._session.cluster.ssl_options is not None:
158+
cert_validation = _ssl_options_cert_validation_enabled(self._session.cluster.ssl_options)
152159
except Exception as e:
153160
log.debug('Unable to get the cert validation: {}'.format(e))
154161

@@ -186,7 +193,8 @@ def _get_startup_data(self):
186193
'compression': compression_type.upper() if compression_type else 'NONE',
187194
'reconnectionPolicy': insights_registry.serialize(self._session.cluster.reconnection_policy),
188195
'sslConfigured': {
189-
'enabled': bool(self._session.cluster.ssl_options or self._session.cluster.ssl_context),
196+
'enabled': (self._session.cluster.ssl_context is not None or
197+
self._session.cluster.ssl_options is not None),
190198
'certValidation': cert_validation
191199
},
192200
'authProvider': {

cassandra/io/asyncioreactor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def __init__(self, *args, **kwargs):
133133
Connection.__init__(self, *args, **kwargs)
134134
self._background_tasks = set()
135135
self._transport = None
136-
self._using_ssl = bool(self.ssl_context)
136+
self._using_ssl = self._ssl_enabled
137137

138138
self._connect_socket()
139139
self._socket.setblocking(0)

cassandra/io/eventletreactor.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
from threading import Event
2424
import time
2525

26-
from cassandra.connection import Connection, ConnectionShutdown, Timer, TimerManager
26+
from cassandra.connection import (Connection, ConnectionShutdown, Timer, TimerManager,
27+
_validate_pyopenssl_hostname)
2728
try:
2829
from eventlet.green.OpenSSL import SSL
2930
_PYOPENSSL = True
@@ -35,6 +36,11 @@
3536
log = logging.getLogger(__name__)
3637

3738

39+
def _default_ssl_method():
40+
return getattr(SSL, 'TLS_CLIENT_METHOD',
41+
getattr(SSL, 'TLS_METHOD', SSL.TLSv1_METHOD))
42+
43+
3844
def _check_pyopenssl():
3945
if not _PYOPENSSL:
4046
raise ImportError(
@@ -43,6 +49,28 @@ def _check_pyopenssl():
4349
)
4450

4551

52+
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
72+
73+
4674
class EventletConnection(Connection):
4775
"""
4876
An implementation of :class:`.Connection` that utilizes ``eventlet``.
@@ -92,7 +120,7 @@ def service_timeouts(cls):
92120

93121
def __init__(self, *args, **kwargs):
94122
Connection.__init__(self, *args, **kwargs)
95-
self.uses_legacy_ssl_options = self.ssl_options and not self.ssl_context
123+
self.uses_legacy_ssl_options = False
96124
self._write_queue = Queue()
97125

98126
self._connect_socket()
@@ -108,23 +136,26 @@ def _wrap_socket_from_context(self):
108136
if self.ssl_options and 'server_hostname' in self.ssl_options:
109137
# This is necessary for SNI
110138
self._socket.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
139+
return self._socket
111140

112141
def _initiate_connection(self, sockaddr):
113142
if self.uses_legacy_ssl_options:
114143
super(EventletConnection, self)._initiate_connection(sockaddr)
115144
else:
116145
self._socket.connect(sockaddr)
117-
if self.ssl_context or self.ssl_options:
146+
if self._ssl_enabled:
118147
self._socket.do_handshake()
119148

120-
def _match_hostname(self):
149+
def _validate_hostname(self):
121150
if self.uses_legacy_ssl_options:
122-
super(EventletConnection, self)._match_hostname()
151+
super(EventletConnection, self)._validate_hostname()
123152
else:
124-
cert_name = self._socket.get_peer_certificate().get_subject().commonName
125-
if cert_name != self.endpoint.address:
126-
raise Exception("Hostname verification failed! Certificate name '{}' "
127-
"doesn't endpoint '{}'".format(cert_name, self.endpoint.address))
153+
expected_name = (self.ssl_options or {}).get('server_hostname') or self.endpoint.address
154+
_validate_pyopenssl_hostname(self._socket.get_peer_certificate(), expected_name)
155+
156+
def _build_ssl_context_from_options(self):
157+
_check_pyopenssl()
158+
return _build_pyopenssl_context_from_options(self.ssl_options)
128159

129160
def close(self):
130161
with self.lock:

0 commit comments

Comments
 (0)