Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cassandra/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,8 @@ def __init__(self,

if cloud is not None:
self.cloud = cloud
if contact_points is not _NOT_SET or endpoint_factory or ssl_context or ssl_options:
if (contact_points is not _NOT_SET or endpoint_factory or
ssl_context is not None or ssl_options is not None):
raise ValueError("contact_points, endpoint_factory, ssl_context, and ssl_options "
"cannot be specified with a cloud configuration")

Expand Down Expand Up @@ -1349,7 +1350,7 @@ def __init__(self,
# connections go through NLB proxies whose addresses won't match
# server certificates.
_check_hostname_enabled = False
if ssl_context is not None and ssl_context.check_hostname:
if ssl_context is not None and getattr(ssl_context, 'check_hostname', False):
_check_hostname_enabled = True
if ssl_options is not None and ssl_options.get('check_hostname', False):
_check_hostname_enabled = True
Expand Down Expand Up @@ -1508,7 +1509,7 @@ def __init__(self,

self.metrics_enabled = metrics_enabled

if ssl_options and not ssl_context:
if ssl_options is not None and ssl_context is None:
warn('Using ssl_options without ssl_context is '
'deprecated and will result in an error in '
'the next major release. Please use ssl_context '
Expand Down
212 changes: 204 additions & 8 deletions cassandra/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from functools import wraps, partial, total_ordering
from heapq import heappush, heappop
import io
import ipaddress
import logging
import socket
import struct
Expand Down Expand Up @@ -55,6 +56,184 @@

log = logging.getLogger(__name__)


def _pyopenssl_cert_to_ssl_cert(peer_cert):
from cryptography import x509
from cryptography.x509.oid import NameOID

cert = peer_cert.to_cryptography()
ssl_cert = {}

subject = tuple((('commonName', attr.value),)
for attr in cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME))
if subject:
ssl_cert['subject'] = subject

subject_alt_names = []
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
except x509.ExtensionNotFound:
pass
else:
subject_alt_names.extend(('DNS', name) for name in san.get_values_for_type(x509.DNSName))
subject_alt_names.extend(('IP Address', str(address))
for address in san.get_values_for_type(x509.IPAddress))

if subject_alt_names:
ssl_cert['subjectAltName'] = tuple(subject_alt_names)

return ssl_cert


def _validate_pyopenssl_hostname(peer_cert, hostname):
cert = _pyopenssl_cert_to_ssl_cert(peer_cert)
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None

names = []
for key, value in cert.get('subjectAltName', ()):
if key == 'DNS':
if host_ip is None and _dnsname_match(value, hostname):
return
names.append(value)
elif key == 'IP Address':
if host_ip is not None and _ipaddress_match(value, host_ip):
return
names.append(value)
Comment on lines +60 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against peer_cert being None.

_pyopenssl_cert_to_ssl_cert calls peer_cert.to_cryptography() unconditionally. pyOpenSSL's get_peer_certificate() can return None when no certificate is presented, which would raise an AttributeError here instead of a clear ssl.CertificateError. Both EventletConnection._validate_hostname and the Twisted handshake info_callback invoke _validate_pyopenssl_hostname without a None-check on the peer cert.

🛡️ Proposed fix
 def _validate_pyopenssl_hostname(peer_cert, hostname):
+    if peer_cert is None:
+        raise ssl.CertificateError("no certificate was presented by the peer")
     cert = _pyopenssl_cert_to_ssl_cert(peer_cert)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _pyopenssl_cert_to_ssl_cert(peer_cert):
from cryptography import x509
from cryptography.x509.oid import NameOID
cert = peer_cert.to_cryptography()
ssl_cert = {}
subject = tuple((('commonName', attr.value),)
for attr in cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME))
if subject:
ssl_cert['subject'] = subject
subject_alt_names = []
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
except x509.ExtensionNotFound:
pass
else:
subject_alt_names.extend(('DNS', name) for name in san.get_values_for_type(x509.DNSName))
subject_alt_names.extend(('IP Address', str(address))
for address in san.get_values_for_type(x509.IPAddress))
if subject_alt_names:
ssl_cert['subjectAltName'] = tuple(subject_alt_names)
return ssl_cert
def _validate_pyopenssl_hostname(peer_cert, hostname):
cert = _pyopenssl_cert_to_ssl_cert(peer_cert)
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
names = []
for key, value in cert.get('subjectAltName', ()):
if key == 'DNS':
if host_ip is None and _dnsname_match(value, hostname):
return
names.append(value)
elif key == 'IP Address':
if host_ip is not None and _ipaddress_match(value, host_ip):
return
names.append(value)
def _pyopenssl_cert_to_ssl_cert(peer_cert):
from cryptography import x509
from cryptography.x509.oid import NameOID
cert = peer_cert.to_cryptography()
ssl_cert = {}
subject = tuple((('commonName', attr.value),)
for attr in cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME))
if subject:
ssl_cert['subject'] = subject
subject_alt_names = []
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
except x509.ExtensionNotFound:
pass
else:
subject_alt_names.extend(('DNS', name) for name in san.get_values_for_type(x509.DNSName))
subject_alt_names.extend(('IP Address', str(address))
for address in san.get_values_for_type(x509.IPAddress))
if subject_alt_names:
ssl_cert['subjectAltName'] = tuple(subject_alt_names)
return ssl_cert
def _validate_pyopenssl_hostname(peer_cert, hostname):
if peer_cert is None:
raise ssl.CertificateError("no certificate was presented by the peer")
cert = _pyopenssl_cert_to_ssl_cert(peer_cert)
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
names = []
for key, value in cert.get('subjectAltName', ()):
if key == 'DNS':
if host_ip is None and _dnsname_match(value, hostname):
return
names.append(value)
elif key == 'IP Address':
if host_ip is not None and _ipaddress_match(value, host_ip):
return
names.append(value)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cassandra/connection.py` around lines 60 - 104, Update
_validate_pyopenssl_hostname and _pyopenssl_cert_to_ssl_cert to handle a None
peer_cert from get_peer_certificate(), raising the established
ssl.CertificateError instead of calling to_cryptography() and producing
AttributeError. Ensure both EventletConnection._validate_hostname and the
Twisted handshake info_callback retain this clear certificate-validation
behavior without requiring separate None checks.


if not names:
for subject in cert.get('subject', ()):
for key, value in subject:
if key == 'commonName':
if _dnsname_match(value, hostname):
return
names.append(value)

if len(names) > 1:
raise ssl.CertificateError("hostname {!r} doesn't match either of {}".format(
hostname, ', '.join(map(repr, names))))
if len(names) == 1:
raise ssl.CertificateError("hostname {!r} doesn't match {!r}".format(hostname, names[0]))
raise ssl.CertificateError("no appropriate commonName or subjectAltName fields were found")


def _dnsname_match(pattern, hostname):
if not pattern:
return False

wildcards = pattern.count('*')
if not wildcards:
return pattern.lower() == hostname.lower()
if wildcards > 1:
raise ssl.CertificateError(
"too many wildcards in certificate DNS name: {!r}.".format(pattern))

leftmost, sep, remainder = pattern.partition('.')
if '*' in remainder:
raise ssl.CertificateError(
"wildcard can only be present in the leftmost label: {!r}.".format(pattern))
if not sep:
raise ssl.CertificateError(
"sole wildcard without additional labels is not supported: {!r}.".format(pattern))
if leftmost != '*':
raise ssl.CertificateError(
"partial wildcards in leftmost label are not supported: {!r}.".format(pattern))

hostname_leftmost, sep, hostname_remainder = hostname.partition('.')
if not hostname_leftmost or not sep:
return False
return remainder.lower() == hostname_remainder.lower()


def _ipaddress_match(pattern, host_ip):
try:
return ipaddress.ip_address(pattern) == host_ip
except ValueError:
return False


def _default_pyopenssl_ssl_method(ssl_module):
for method_name in ('TLS_CLIENT_METHOD', 'TLS_METHOD', 'TLSv1_2_METHOD'):
method = getattr(ssl_module, method_name, None)
if method is not None:
return method
raise ImportError('pyOpenSSL does not expose a secure TLS client method')


def _pyopenssl_ssl_method_from_ssl_version(ssl_version, ssl_module):
if not isinstance(ssl_version, type(ssl.PROTOCOL_TLS)):
return ssl_version

protocol_method_names = (
('PROTOCOL_TLS', None),
('PROTOCOL_SSLv23', None),
('PROTOCOL_TLS_CLIENT', None),
('PROTOCOL_TLS_SERVER', ('TLS_SERVER_METHOD', 'TLS_METHOD')),
('PROTOCOL_TLSv1', ('TLSv1_METHOD',)),
('PROTOCOL_TLSv1_1', ('TLSv1_1_METHOD',)),
('PROTOCOL_TLSv1_2', ('TLSv1_2_METHOD',)),
)
for protocol_name, method_names in protocol_method_names:
if getattr(ssl, protocol_name, None) is ssl_version:
if method_names is None:
return _default_pyopenssl_ssl_method(ssl_module)
for method_name in method_names:
method = getattr(ssl_module, method_name, None)
if method is not None:
return method
raise ValueError('pyOpenSSL does not expose a method for {}'.format(protocol_name))

return ssl_version


def _pyopenssl_verify_mode_from_cert_reqs(cert_reqs, ssl_module):
if not isinstance(cert_reqs, type(ssl.CERT_NONE)):
return cert_reqs
if cert_reqs == ssl.CERT_NONE:
return ssl_module.VERIFY_NONE
if cert_reqs in (ssl.CERT_OPTIONAL, ssl.CERT_REQUIRED):
return ssl_module.VERIFY_PEER
return cert_reqs


def _build_pyopenssl_context_from_options(ssl_options, ssl_module):
ssl_version = (ssl_options['ssl_version']
if 'ssl_version' in ssl_options else _default_pyopenssl_ssl_method(ssl_module))
ssl_version = _pyopenssl_ssl_method_from_ssl_version(ssl_version, ssl_module)
context = ssl_module.Context(ssl_version)
certfile = ssl_options.get('certfile', None)
keyfile = ssl_options.get('keyfile', None)
if certfile:
use_certificate_chain_file = getattr(context, 'use_certificate_chain_file', None)
if use_certificate_chain_file:
use_certificate_chain_file(certfile)
else:
context.use_certificate_file(certfile)
context.use_privatekey_file(keyfile or certfile)
context.check_privatekey()
elif keyfile:
context.use_privatekey_file(keyfile)
ca_certs = ssl_options.get('ca_certs', None)
if ca_certs:
context.load_verify_locations(ca_certs)
ciphers = ssl_options.get('ciphers', None)
if ciphers:
context.set_cipher_list(ciphers)
cert_reqs = ssl_options.get('cert_reqs', None)
if cert_reqs is None:
cert_reqs = (ssl_module.VERIFY_PEER
if (ssl_options.get('ca_certs', None) or ssl_options.get('check_hostname', False))
else ssl_module.VERIFY_NONE)
cert_reqs = _pyopenssl_verify_mode_from_cert_reqs(cert_reqs, ssl_module)
context.set_verify(
cert_reqs,
callback=lambda _connection, _x509, _errnum, _errdepth, ok: ok
)
return context


segment_codec_no_compression = SegmentCodec()
segment_codec_lz4 = None

Expand Down Expand Up @@ -803,6 +982,7 @@ class Connection(object):
endpoint = None
ssl_options = None
ssl_context = None
_ssl_options_explicit = False
last_error = None

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

self.authenticator = authenticator
self.ssl_options = ssl_options.copy() if ssl_options else {}
endpoint_ssl_options = self.endpoint.ssl_options
# Explicit ssl_options={} enables SSL with default options; omitted
# ssl_options=None leaves SSL disabled unless an endpoint supplies options.
self._ssl_options_explicit = ssl_options is not None
self.ssl_options = ssl_options.copy() if ssl_options is not None else {}
self.ssl_context = ssl_context
self.sockopts = sockopts
self.compression = compression
Expand All @@ -905,10 +1089,13 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
self._on_orphaned_stream_released = on_orphaned_stream_released
self._application_info = application_info

if ssl_options:
self.ssl_options.update(self.endpoint.ssl_options or {})
elif self.endpoint.ssl_options:
self.ssl_options = self.endpoint.ssl_options
if ssl_options is not None:
self.ssl_options.update(endpoint_ssl_options or {})
elif endpoint_ssl_options is not None:
self._ssl_options_explicit = True
self.ssl_options = endpoint_ssl_options
self._check_hostname = bool(self.ssl_options.get('check_hostname', False) or
getattr(self.ssl_context, 'check_hostname', False))

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

self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
Expand All @@ -942,6 +1129,10 @@ def host(self):
def port(self):
return self.endpoint.port

@property
def _ssl_enabled(self):
return self.ssl_context is not None or self._ssl_options_explicit

@classmethod
def initialize_reactor(cls):
"""
Expand Down Expand Up @@ -1000,10 +1191,15 @@ def _build_ssl_context_from_options(self):
# Python >= 3.10 requires either PROTOCOL_TLS_CLIENT or PROTOCOL_TLS_SERVER so we'll get ahead of things by always
# being explicit
ssl_version = opts.get('ssl_version', None) or ssl.PROTOCOL_TLS_CLIENT
cert_reqs = opts.get('cert_reqs', None) or ssl.CERT_REQUIRED
cert_reqs = opts.get('cert_reqs', None)
if cert_reqs is None:
cert_reqs = (ssl.CERT_REQUIRED
if (opts.get('ca_certs', None) or opts.get('check_hostname', False))
else ssl.CERT_NONE)
rv = ssl.SSLContext(protocol=int(ssl_version))
rv.check_hostname = False
rv.verify_mode = cert_reqs
rv.check_hostname = bool(opts.get('check_hostname', False))
rv.options = int(cert_reqs)

certfile = opts.get('certfile', None)
keyfile = opts.get('keyfile', None)
Expand Down
29 changes: 16 additions & 13 deletions cassandra/datastax/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from zipfile import BadZipfile as BadZipFile

from cassandra import DriverException
from cassandra.connection import _build_pyopenssl_context_from_options

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -89,7 +90,7 @@ def get_cloud_config(cloud_config, create_pyopenssl_context=False):

config = read_metadata_info(config, cloud_config)
if create_pyopenssl_context:
config.ssl_context = config.pyopenssl_context
config.ssl_context = getattr(config, 'pyopenssl_context', config.ssl_context)
return config


Expand All @@ -112,19 +113,19 @@ def parse_cloud_config(path, cloud_config, create_pyopenssl_context):

config = CloudConfig.from_dict(data)
config_dir = os.path.dirname(path)
ca_cert_location = os.path.join(config_dir, 'ca.crt')
cert_location = os.path.join(config_dir, 'cert')
key_location = os.path.join(config_dir, 'key')

if 'ssl_context' in cloud_config:
config.ssl_context = cloud_config['ssl_context']
else:
# Load the ssl_context before we delete the temporary directory
ca_cert_location = os.path.join(config_dir, 'ca.crt')
cert_location = os.path.join(config_dir, 'cert')
key_location = os.path.join(config_dir, 'key')
# Regardless of if we create a pyopenssl context, we still need the builtin one
# to connect to the metadata service
config.ssl_context = _ssl_context_from_cert(ca_cert_location, cert_location, key_location)
if create_pyopenssl_context:
config.pyopenssl_context = _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location)
if create_pyopenssl_context:
config.pyopenssl_context = _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location)

return config

Expand Down Expand Up @@ -183,10 +184,12 @@ def _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location):
raise ImportError(
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops")\
.with_traceback(e.__traceback__)
ssl_context = SSL.Context(SSL.TLSv1_METHOD)
ssl_context.set_verify(SSL.VERIFY_PEER, callback=lambda _1, _2, _3, _4, ok: ok)
ssl_context.use_certificate_file(cert_location)
ssl_context.use_privatekey_file(key_location)
ssl_context.load_verify_locations(ca_cert_location)

return ssl_context
return _build_pyopenssl_context_from_options(
{
'ca_certs': ca_cert_location,
'certfile': cert_location,
'keyfile': key_location,
'cert_reqs': SSL.VERIFY_PEER,
},
SSL
)
Loading
Loading