Skip to content

PYTHON-1331 ssl.match_hostname() is deprecated in 3.7 #1191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Dec 19, 2023
Merged
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
8 changes: 6 additions & 2 deletions Jenkinsfile
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ def initializeEnvironment() {
// Determine if server version is Apache CassandraⓇ or DataStax Enterprise
if (env.CASSANDRA_VERSION.split('-')[0] == 'dse') {
if (env.PYTHON_VERSION =~ /3\.12\.\d+/) {
echo "Cannot install DSE dependencies for Python 3.12.x. See PYTHON-1368 for more detail."
echo "Cannot install DSE dependencies for Python 3.12.x; installing Apache CassandraⓇ requirements only. See PYTHON-1368 for more detail."
sh label: 'Install Apache CassandraⓇ requirements', script: '''#!/bin/bash -lex
pip install -r test-requirements.txt
'''
}
else {
sh label: 'Install DataStax Enterprise requirements', script: '''#!/bin/bash -lex
Expand All @@ -196,7 +199,8 @@ def initializeEnvironment() {
}

sh label: 'Install unit test modules', script: '''#!/bin/bash -lex
pip install pynose nose-ignore-docstring nose-exclude service_identity
pip install --no-deps nose-ignore-docstring nose-exclude
pip install service_identity
'''

if (env.CYTHON_ENABLED == 'True') {
Expand Down
2 changes: 1 addition & 1 deletion cassandra/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ class DependencyException(Exception):
def __init__(self, msg, excs=[]):
complete_msg = msg
if excs:
complete_msg += ("The following exceptions were observed: \n" + '\n'.join(str(e) for e in excs))
complete_msg += ("\nThe following exceptions were observed: \n - " + '\n - '.join(str(e) for e in excs))
Exception.__init__(self, complete_msg)

class VectorDeserializationFailure(DriverException):
Expand Down
20 changes: 13 additions & 7 deletions cassandra/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,12 @@ def _connection_reduce_fn(val,import_fn):
excs.append(exc)
return (rv or import_result, excs)

log = logging.getLogger(__name__)

conn_fns = (_try_gevent_import, _try_eventlet_import, _try_libev_import, _try_asyncore_import)
(conn_class, excs) = reduce(_connection_reduce_fn, conn_fns, (None,[]))
if excs:
raise DependencyException("Exception loading connection class dependencies", excs)
if not conn_class:
raise DependencyException("Unable to load a default connection class", excs)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

A fix for an issue discovered in testing. Goal of the changes to support asyncore as an optional dependency was to raise an error if we were unable to find a workable event loop at all. Unfortunately my original impl just looked for the presence of any exception in any event loop impl which is... not at all the same thing.

DefaultConnection = conn_class

# Forces load of utf8 encoding module to avoid deadlock that occurs
Expand All @@ -177,8 +179,6 @@ def _connection_reduce_fn(val,import_fn):
# See http://bugs.python.org/issue10923
"".encode('utf8')

log = logging.getLogger(__name__)


DEFAULT_MIN_REQUESTS = 5
DEFAULT_MAX_REQUESTS = 100
Expand Down Expand Up @@ -811,9 +811,9 @@ def default_retry_policy(self, policy):
Using ssl_options without ssl_context is deprecated and will be removed in the
next major release.

An optional dict which will be used as kwargs for ``ssl.SSLContext.wrap_socket`` (or
``ssl.wrap_socket()`` if used without ssl_context) when new sockets are created.
This should be used when client encryption is enabled in Cassandra.
An optional dict which will be used as kwargs for ``ssl.SSLContext.wrap_socket``
when new sockets are created. This should be used when client encryption is enabled
in Cassandra.

The following documentation only applies when ssl_options is used without ssl_context.

Expand All @@ -829,6 +829,12 @@ def default_retry_policy(self, policy):
should almost always require the option ``'cert_reqs': ssl.CERT_REQUIRED``. Note also that this functionality was not built into
Python standard library until (2.7.9, 3.2). To enable this mechanism in earlier versions, patch ``ssl.match_hostname``
with a custom or `back-ported function <https://pypi.org/project/backports.ssl_match_hostname/>`_.

.. versionchanged:: 3.29.0

``ssl.match_hostname`` has been deprecated since Python 3.7 (and removed in Python 3.12). This functionality is now implemented
via ``ssl.SSLContext.check_hostname``. All options specified above (including ``check_hostname``) should continue to behave in a
way that is consistent with prior implementations.
"""

ssl_context = None
Expand Down
84 changes: 63 additions & 21 deletions cassandra/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,6 @@ class Connection(object):
_socket = None

_socket_impl = socket
_ssl_impl = ssl

_check_hostname = False
_product_type = None
Expand All @@ -757,7 +756,7 @@ 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 None
self.ssl_options = ssl_options.copy() if ssl_options else {}
self.ssl_context = ssl_context
self.sockopts = sockopts
self.compression = compression
Expand All @@ -777,15 +776,20 @@ def __init__(self, host='127.0.0.1', port=9042, authenticator=None,
self._on_orphaned_stream_released = on_orphaned_stream_released

if ssl_options:
self._check_hostname = bool(self.ssl_options.pop('check_hostname', False))
if self._check_hostname:
if not getattr(ssl, 'match_hostname', None):
raise RuntimeError("ssl_options specify 'check_hostname', but ssl.match_hostname is not provided. "
"Patch or upgrade Python to use this option.")
self.ssl_options.update(self.endpoint.ssl_options or {})
elif self.endpoint.ssl_options:
self.ssl_options = self.endpoint.ssl_options

# PYTHON-1331
#
# We always use SSLContext.wrap_socket() now but legacy configs may have other params that were passed to ssl.wrap_socket()...
# and either could have 'check_hostname'. Remove these params into a separate map and use them to build an SSLContext if
# we need to do so.
#
# 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:
self.ssl_context = self._build_ssl_context_from_options()

if protocol_version >= 3:
self.max_request_id = min(self.max_in_flight - 1, (2 ** 15) - 1)
Expand Down Expand Up @@ -852,21 +856,57 @@ def factory(cls, endpoint, timeout, *args, **kwargs):
else:
return conn

def _build_ssl_context_from_options(self):

# Extract a subset of names from self.ssl_options which apply to SSLContext creation
ssl_context_opt_names = ['ssl_version', 'cert_reqs', 'check_hostname', 'keyfile', 'certfile', 'ca_certs', 'ciphers']
opts = {k:self.ssl_options.get(k, None) for k in ssl_context_opt_names if k in self.ssl_options}

# 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
rv = ssl.SSLContext(protocol=int(ssl_version))
rv.check_hostname = bool(opts.get('check_hostname', False))
rv.options = int(cert_reqs)

certfile = opts.get('certfile', None)
keyfile = opts.get('keyfile', None)
if certfile:
rv.load_cert_chain(certfile, keyfile)
ca_certs = opts.get('ca_certs', None)
if ca_certs:
rv.load_verify_locations(ca_certs)
ciphers = opts.get('ciphers', None)
if ciphers:
rv.set_ciphers(ciphers)

return rv
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Follows the idea roughly sketched out in Python docs for ssl.wrap_socket()


def _wrap_socket_from_context(self):
ssl_options = self.ssl_options or {}

# Extract a subset of names from self.ssl_options which apply to SSLContext.wrap_socket (or at least the parts
# of it that don't involve building an SSLContext under the covers)
wrap_socket_opt_names = ['server_side', 'do_handshake_on_connect', 'suppress_ragged_eofs', 'server_hostname']
opts = {k:self.ssl_options.get(k, None) for k in wrap_socket_opt_names if k in self.ssl_options}

# PYTHON-1186: set the server_hostname only if the SSLContext has
# check_hostname enabled and it is not already provided by the EndPoint ssl options
if (self.ssl_context.check_hostname and
'server_hostname' not in ssl_options):
ssl_options = ssl_options.copy()
ssl_options['server_hostname'] = self.endpoint.address
self._socket = self.ssl_context.wrap_socket(self._socket, **ssl_options)
#opts['server_hostname'] = self.endpoint.address
if (self.ssl_context.check_hostname and 'server_hostname' not in opts):
server_hostname = self.endpoint.address
opts['server_hostname'] = server_hostname

return self.ssl_context.wrap_socket(self._socket, **opts)

def _initiate_connection(self, sockaddr):
self._socket.connect(sockaddr)

def _match_hostname(self):
ssl.match_hostname(self._socket.getpeercert(), self.endpoint.address)
# PYTHON-1331
#
# Allow implementations specific to an event loop to add additional behaviours
def _validate_hostname(self):
pass

def _get_socket_addresses(self):
address, port = self.endpoint.resolve()
Expand All @@ -887,16 +927,18 @@ def _connect_socket(self):
try:
self._socket = self._socket_impl.socket(af, socktype, proto)
if self.ssl_context:
self._wrap_socket_from_context()
elif self.ssl_options:
if not self._ssl_impl:
raise RuntimeError("This version of Python was not compiled with SSL support")
self._socket = self._ssl_impl.wrap_socket(self._socket, **self.ssl_options)
self._socket = self._wrap_socket_from_context()
self._socket.settimeout(self.connect_timeout)
self._initiate_connection(sockaddr)
self._socket.settimeout(None)

# PYTHON-1331
#
# Most checking is done via the check_hostname param on the SSLContext.
# Subclasses can add additional behaviours via _validate_hostname() so
# run that here.
if self._check_hostname:
self._match_hostname()
self._validate_hostname()
sockerr = None
break
except socket.error as err:
Expand Down
15 changes: 7 additions & 8 deletions cassandra/io/eventletreactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,12 @@ def __init__(self, *args, **kwargs):

def _wrap_socket_from_context(self):
_check_pyopenssl()
self._socket = SSL.Connection(self.ssl_context, self._socket)
self._socket.set_connect_state()
rv = SSL.Connection(self.ssl_context, self._socket)
rv.set_connect_state()
if self.ssl_options and 'server_hostname' in self.ssl_options:
# This is necessary for SNI
self._socket.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
rv.set_tlsext_host_name(self.ssl_options['server_hostname'].encode('ascii'))
return rv

def _initiate_connection(self, sockaddr):
if self.uses_legacy_ssl_options:
Expand All @@ -117,14 +118,12 @@ def _initiate_connection(self, sockaddr):
if self.ssl_context or self.ssl_options:
self._socket.do_handshake()

def _match_hostname(self):
if self.uses_legacy_ssl_options:
super(EventletConnection, self)._match_hostname()
else:
def _validate_hostname(self):
if not self.uses_legacy_ssl_options:
cert_name = self._socket.get_peer_certificate().get_subject().commonName
if cert_name != self.endpoint.address:
raise Exception("Hostname verification failed! Certificate name '{}' "
"doesn't endpoint '{}'".format(cert_name, self.endpoint.address))
"doesn't match endpoint '{}'".format(cert_name, self.endpoint.address))

def close(self):
with self.lock:
Expand Down
2 changes: 1 addition & 1 deletion test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-r requirements.txt
scales
nose
pynose
mock>1.1
ccm>=2.1.2
pytz
Expand Down