From 1d7b01dfa076ad810ba2dfc7152b1a888d9ead11 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Fri, 31 Jul 2026 11:42:27 -0400 Subject: [PATCH] cluster: make ssl_options a migration error --- CHANGELOG.rst | 4 + cassandra/cluster.py | 74 ++++---- docs/security.rst | 134 ++++++++++++-- tests/integration/long/test_ssl.py | 288 ++++++++++++----------------- tests/unit/test_client_routes.py | 5 +- tests/unit/test_cluster.py | 27 +++ 6 files changed, 310 insertions(+), 222 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2a02f1ac54..d0fc085b3a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,10 @@ Features Others ------ +* The deprecated ``Cluster.ssl_options`` parameter is retained as a migration + aid but can no longer configure TLS. Supplying or assigning it, including an + empty dictionary, raises ``ValueError`` with guidance for configuring an + ``ssl.SSLContext`` and passing it using ``ssl_context`` instead. * ``PreparedStatement.result_metadata`` and ``PreparedStatement.result_metadata_id`` are now read-only. They are replaced together by ``PreparedStatement.update_result_metadata()``, so a request can never observe a metadata diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 88c8d2707a..50558c7849 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -874,45 +874,28 @@ def default_retry_policy(self, policy): :const:`True`, else :const:`None`. """ - ssl_options = None - """ - 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`` - 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. - - By default, a ``ca_certs`` value should be supplied (the value should be - a string pointing to the location of the CA certs file), and you probably - want to specify ``ssl_version`` as ``ssl.PROTOCOL_TLS`` to match - Cassandra's default protocol. - - .. versionchanged:: 3.3.0 + _ssl_options = None - In addition to ``wrap_socket`` kwargs, clients may also specify ``'check_hostname': True`` to verify the cert hostname - as outlined in RFC 2818 and RFC 6125. Note that this requires the certificate to be transferred, so - 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 `_. - - .. versionchanged:: 3.29.0 + @property + def ssl_options(self): + """ + Deprecated TLS configuration option retained to provide migration + guidance. Passing or assigning a value raises :class:`ValueError`. + Configure an ``ssl.SSLContext`` and pass it using + :attr:`.ssl_context` instead. + """ + return None - ``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_options.setter + def ssl_options(self, value): + if value is not None: + self._raise_ssl_options_migration_error() ssl_context = None """ An optional ``ssl.SSLContext`` instance which will be used when new sockets are created. This should be used when client encryption is enabled in Cassandra. - ``wrap_socket`` options can be set using :attr:`~Cluster.ssl_options`. ssl_options will - be used as kwargs for ``ssl.SSLContext.wrap_socket``. - .. versionadded:: 3.17.0 """ @@ -1277,6 +1260,9 @@ def __init__(self, Any of the mutable Cluster attributes may be set as keyword arguments to the constructor. """ + if ssl_options is not None: + self._raise_ssl_options_migration_error() + # Handle port passed as string if isinstance(port, str): if not port.isdigit(): @@ -1508,14 +1494,10 @@ def __init__(self, self.metrics_enabled = metrics_enabled - if ssl_options and not ssl_context: - warn('Using ssl_options without ssl_context is ' - 'deprecated and will result in an error in ' - 'the next major release. Please use ssl_context ' - 'to prepare for that release.', - DeprecationWarning) - - self.ssl_options = ssl_options + # Cloud configuration still carries internal per-endpoint TLS routing + # metadata. Keep it private so callers cannot accidentally reach the + # legacy public ssl_options path. + self._ssl_options = ssl_options self.ssl_context = ssl_context self.sockopts = sockopts self.cql_version = cql_version @@ -1762,7 +1744,7 @@ def _make_connection_kwargs(self, endpoint, kwargs_dict): kwargs_dict.setdefault('port', self.port) kwargs_dict.setdefault('compression', self.compression) kwargs_dict.setdefault('sockopts', self.sockopts) - kwargs_dict.setdefault('ssl_options', self.ssl_options) + kwargs_dict.setdefault('ssl_options', self._ssl_options) kwargs_dict.setdefault('ssl_context', self.ssl_context) kwargs_dict.setdefault('cql_version', self.cql_version) kwargs_dict.setdefault('protocol_version', self.protocol_version) @@ -1773,6 +1755,18 @@ def _make_connection_kwargs(self, endpoint, kwargs_dict): return kwargs_dict + @staticmethod + def _raise_ssl_options_migration_error(): + raise ValueError( + "ssl_options is deprecated and can no longer configure TLS. " + "Create an ssl.SSLContext and pass it using ssl_context instead. " + "For example, ca_certs maps to " + "SSLContext.load_verify_locations(), and certfile/keyfile map to " + "SSLContext.load_cert_chain(). Migration guide: " + "https://python-driver.docs.scylladb.com/stable/" + "security.html#ssl-options-migration" + ) + def protocol_downgrade(self, host_endpoint, previous_version): if self._protocol_version_explicit: raise DriverException("ProtocolError returned from server while using explicitly set client protocol_version %d" % (previous_version,)) diff --git a/docs/security.rst b/docs/security.rst index 5c8645e685..64a980640b 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -47,17 +47,124 @@ These docs will include some examples for how to achieve common configurations, but the `ssl.SSLContext `_ documentation gives a more complete description of what is possible. -To enable SSL with version 3.17.0 and higher, you will need to set :attr:`.Cluster.ssl_context` to a -``ssl.SSLContext`` instance to enable SSL. Optionally, you can also set :attr:`.Cluster.ssl_options` -to a dict of options. These will be passed as kwargs to ``ssl.SSLContext.wrap_socket()`` -when new sockets are created. +To enable SSL with version 3.17.0 and higher, set :attr:`.Cluster.ssl_context` to an +``ssl.SSLContext`` instance. The legacy :attr:`.Cluster.ssl_options` argument remains +in the API only to raise an error with migration guidance when it is used. + +.. _ssl-options-migration: + +Migrating from ``ssl_options`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Replace the legacy options dictionary with a context configured through the +standard-library ``ssl`` API. For example, replace: + +.. code-block:: python + + import ssl + from cassandra.cluster import Cluster + + cluster = Cluster( + ['node.example.com'], + ssl_options={ + 'ca_certs': '/path/to/rootca.pem', + 'cert_reqs': ssl.CERT_REQUIRED, + 'check_hostname': True, + }, + ) + +with: + +.. code-block:: python + + context = ssl.create_default_context(cafile='/path/to/rootca.pem') + context.check_hostname = True + + cluster = Cluster( + ['node.example.com'], + ssl_context=context, + ) + +Use the following mappings for other legacy options: + +.. list-table:: ``ssl_options`` migration reference + :header-rows: 1 + :widths: 22 38 40 + + * - Legacy option + - Standard-library context + - Twisted/Eventlet pyOpenSSL context + * - ``ca_certs`` + - ``context.load_verify_locations(path)`` + - ``context.load_verify_locations(path)`` + * - ``certfile`` and ``keyfile`` + - ``context.load_cert_chain(certfile, keyfile, password)`` + - ``context.use_certificate_file(certfile)`` and + ``context.use_privatekey_file(keyfile)`` + * - ``cert_reqs`` + - ``context.verify_mode`` + - ``context.set_verify(mode, callback)`` + * - ``check_hostname`` + - ``context.check_hostname`` + - There is no direct context attribute. Configure hostname verification + in a vetted pyOpenSSL verification layer, or migrate to a + standard-library-backed reactor when hostname verification is required. + * - ``ciphers`` + - ``context.set_ciphers(value)`` + - ``context.set_cipher_list(value.encode('ascii'))`` + * - ``ssl_version`` + - Start with ``ssl.PROTOCOL_TLS_CLIENT`` and configure + ``minimum_version`` and ``maximum_version`` when pinning is required. + - Start with ``SSL.TLS_CLIENT_METHOD`` and configure protocol bounds on + the context when pinning is required. + * - ``server_hostname`` + - The driver derives this from the endpoint. Use + :class:`~cassandra.connection.SniEndPoint` when explicit SNI routing is + required. + - The driver derives this from the endpoint. Use + :class:`~cassandra.connection.SniEndPoint` when explicit SNI routing is + required. + * - ``server_side``, ``do_handshake_on_connect``, and + ``suppress_ragged_eofs`` + - No migration. The driver owns client-side socket creation and TLS + handshakes; these low-level overrides are no longer configurable. + - No migration. The driver owns client-side socket creation and TLS + handshakes; these low-level overrides are no longer configurable. + +An empty ``ssl_options={}`` previously left the intended verification policy +ambiguous. Replace it with an explicit context. For an insecure development +connection with no certificate verification: + +.. code-block:: python + + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + cluster = Cluster(['127.0.0.1'], ssl_context=context) + +Do not use this configuration in production. Prefer +``ssl.create_default_context()`` with a trusted CA and hostname verification. + +For mutual TLS, load the client certificate and private key on the context: + +.. code-block:: python + + context = ssl.create_default_context(cafile='/path/to/rootca.pem') + context.load_cert_chain( + certfile='/path/to/client.crt', + keyfile='/path/to/client.key', + password='optional-key-password', + ) + cluster = Cluster(['node.example.com'], ssl_context=context) + +Twisted and Eventlet require an ``OpenSSL.SSL.Context`` instead of a standard +``ssl.SSLContext``. See `SSL with Twisted or Eventlet`_ below for a complete +example. Cloud secure-connect bundles require no migration; the driver creates +their TLS context internally. If you create your SSLContext using `ssl.create_default_context `_, -be aware that SSLContext.check_hostname is set to True by default, so the hostname validation will be done -by Python and not the driver. For this reason, we need to set the server_hostname at best effort, which is the -resolved ip address. If this validation needs to be done against the FQDN, consider enabling it using the ssl_options -as described in the following examples or implement your own :class:`~.connection.EndPoint` and -:class:`~.connection.EndPointFactory`. +be aware that SSLContext.check_hostname is set to True by default, so hostname validation is done +by Python rather than the driver. The driver uses the endpoint address as the TLS server name. The following examples assume you have generated your Scylla certificate and @@ -135,7 +242,7 @@ to `CERT_REQUIRED`. Otherwise, the loaded verify certificate will have no effect cluster = Cluster(['127.0.0.1'], ssl_context=ssl_context) session = cluster.connect() -Additionally, you can also force the driver to verify the `hostname` of the server by passing additional options to `ssl_context.wrap_socket` via the `ssl_options` kwarg: +To verify the hostname of the server, enable hostname checking on the context: .. code-block:: python @@ -146,9 +253,7 @@ Additionally, you can also force the driver to verify the `hostname` of the serv ssl_context.load_verify_locations('/path/to/rootca.crt') ssl_context.verify_mode = CERT_REQUIRED ssl_context.check_hostname = True - ssl_options = {'server_hostname': '127.0.0.1'} - - cluster = Cluster(['127.0.0.1'], ssl_context=ssl_context, ssl_options=ssl_options) + cluster = Cluster(['127.0.0.1'], ssl_context=ssl_context) session = cluster.connect() .. _ssl-server-verifies-client: @@ -263,8 +368,7 @@ for more details about ``SSLContext`` configuration. cluster = Cluster( contact_points=['127.0.0.1'], connection_class=TwistedConnection, - ssl_context=ssl_context, - ssl_options={'check_hostname': True} + ssl_context=ssl_context ) session = cluster.connect() diff --git a/tests/integration/long/test_ssl.py b/tests/integration/long/test_ssl.py index 0170f56fa1..51026818d7 100644 --- a/tests/integration/long/test_ssl.py +++ b/tests/integration/long/test_ssl.py @@ -47,22 +47,53 @@ DRIVER_KEYFILE = os.path.abspath("tests/integration/long/ssl/client.key") DRIVER_KEYFILE_ENCRYPTED = os.path.abspath("tests/integration/long/ssl/client_encrypted.key") DRIVER_CERTFILE = os.path.abspath("tests/integration/long/ssl/client.crt_signed") -DRIVER_CERTFILE_BAD = os.path.abspath("tests/integration/long/ssl/client_bad.key") USES_PYOPENSSL = "twisted" in EVENT_LOOP_MANAGER or "eventlet" in EVENT_LOOP_MANAGER -if "twisted" in EVENT_LOOP_MANAGER: - import OpenSSL - ssl_version = OpenSSL.SSL.TLS_METHOD - verify_certs = {'cert_reqs': SSL.VERIFY_PEER, - 'check_hostname': True} -else: - ssl_version = ssl.PROTOCOL_TLS - verify_certs = {'cert_reqs': ssl.CERT_REQUIRED, - 'check_hostname': True} -def verify_callback(connection, x509, errnum, errdepth, ok): - return ok +def create_ssl_context( + ca_certs=CLIENT_CA_CERTS, certfile=None, keyfile=None, + password=None, hostname=None): + if USES_PYOPENSSL: + ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) + if ca_certs: + ssl_context.load_verify_locations(ca_certs) + + def verify_peer(_connection, certificate, _errnum, errdepth, ok): + if not ok: + return False + return (not hostname or errdepth != 0 or + certificate.get_subject().commonName == hostname) + + ssl_context.set_verify(SSL.VERIFY_PEER, verify_peer) + if certfile: + ssl_context.use_certificate_file(certfile) + if keyfile: + if password: + with open(keyfile) as key_file: + key = crypto.load_privatekey( + crypto.FILETYPE_PEM, + key_file.read(), + password.encode('ascii'), + ) + ssl_context.use_privatekey(key) + else: + ssl_context.use_privatekey_file(keyfile) + return ssl_context + + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_REQUIRED + if ca_certs: + ssl_context.load_verify_locations(ca_certs) + if certfile: + ssl_context.load_cert_chain( + certfile=certfile, + keyfile=keyfile, + password=password, + ) + ssl_context.check_hostname = hostname is not None + return ssl_context def setup_cluster_ssl(client_auth=False): @@ -90,44 +121,38 @@ def setup_cluster_ssl(client_auth=False): start_cluster_wait_for_up(ccm_cluster) -def validate_ssl_options(**kwargs): - ssl_options = kwargs.get('ssl_options', None) - ssl_context = kwargs.get('ssl_context', None) - hostname = kwargs.get('hostname', '127.0.0.1') - - # find absolute path to client CA_CERTS - tries = 0 - while True: - if tries > 5: - raise RuntimeError("Failed to connect to SSL cluster after 5 attempts") - try: - cluster = TestCluster( - contact_points=[DefaultEndPoint(hostname)], - ssl_options=ssl_options, - ssl_context=ssl_context - ) - session = cluster.connect(wait_for_all_pools=True) - break - except Exception: - ex_type, ex, tb = sys.exc_info() - log.warning("{0}: {1} Backtrace: {2}".format(ex_type.__name__, ex, traceback.extract_tb(tb))) - del tb - tries += 1 +def validate_ssl_context(ssl_context, hostname='127.0.0.1'): + tries = 0 + while True: + if tries > 5: + raise RuntimeError("Failed to connect to SSL cluster after 5 attempts") + try: + cluster = TestCluster( + contact_points=[DefaultEndPoint(hostname)], + ssl_context=ssl_context + ) + session = cluster.connect(wait_for_all_pools=True) + break + except Exception: + ex_type, ex, tb = sys.exc_info() + log.warning("{0}: {1} Backtrace: {2}".format(ex_type.__name__, ex, traceback.extract_tb(tb))) + del tb + tries += 1 + + # attempt a few simple commands. + insert_keyspace = """CREATE KEYSPACE ssltest + WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'} + """ + statement = SimpleStatement(insert_keyspace) + statement.consistency_level = 3 + session.execute(statement) - # attempt a few simple commands. - insert_keyspace = """CREATE KEYSPACE ssltest - WITH replication = {'class': 'NetworkTopologyStrategy', 'replication_factor': '3'} - """ - statement = SimpleStatement(insert_keyspace) - statement.consistency_level = 3 - session.execute(statement) - - drop_keyspace = "DROP KEYSPACE ssltest" - statement = SimpleStatement(drop_keyspace) - statement.consistency_level = ConsistencyLevel.ANY - session.execute(statement) + drop_keyspace = "DROP KEYSPACE ssltest" + statement = SimpleStatement(drop_keyspace) + statement.consistency_level = ConsistencyLevel.ANY + session.execute(statement) - cluster.shutdown() + cluster.shutdown() class SSLConnectionTests(unittest.TestCase): @@ -159,8 +184,7 @@ def test_can_connect_with_ssl_ca(self): """ # find absolute path to client CA_CERTS - ssl_options = {'ca_certs': CLIENT_CA_CERTS,'ssl_version': ssl_version} - validate_ssl_options(ssl_options=ssl_options) + validate_ssl_context(create_ssl_context()) def test_can_connect_with_ssl_long_running(self): """ @@ -174,15 +198,14 @@ def test_can_connect_with_ssl_long_running(self): """ # find absolute path to client CA_CERTS - abs_path_ca_cert_path = os.path.abspath(CLIENT_CA_CERTS) - ssl_options = {'ca_certs': abs_path_ca_cert_path, - 'ssl_version': ssl_version} + ssl_context = create_ssl_context( + ca_certs=os.path.abspath(CLIENT_CA_CERTS)) tries = 0 while True: if tries > 5: raise RuntimeError("Failed to connect to SSL cluster after 5 attempts") try: - cluster = TestCluster(ssl_options=ssl_options) + cluster = TestCluster(ssl_context=ssl_context) session = cluster.connect(wait_for_all_pools=True) break except Exception: @@ -213,11 +236,7 @@ def test_can_connect_with_ssl_ca_host_match(self): @test_category connection:ssl """ - ssl_options = {'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version} - ssl_options.update(verify_certs) - - validate_ssl_options(ssl_options=ssl_options) + validate_ssl_context(create_ssl_context(hostname='127.0.0.1')) class SSLConnectionAuthTests(unittest.TestCase): @@ -246,11 +265,10 @@ def test_can_connect_with_ssl_client_auth(self): @test_category connection:ssl """ - ssl_options = {'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE, - 'certfile': DRIVER_CERTFILE} - validate_ssl_options(ssl_options=ssl_options) + validate_ssl_context(create_ssl_context( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE, + )) def test_can_connect_with_ssl_client_auth_host_name(self): """ @@ -267,13 +285,11 @@ def test_can_connect_with_ssl_client_auth_host_name(self): @test_category connection:ssl """ - ssl_options = {'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE, - 'certfile': DRIVER_CERTFILE} - ssl_options.update(verify_certs) - - validate_ssl_options(ssl_options=ssl_options) + validate_ssl_context(create_ssl_context( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE, + hostname='127.0.0.1', + )) def test_cannot_connect_without_client_auth(self): """ @@ -288,8 +304,7 @@ def test_cannot_connect_without_client_auth(self): @test_category connection:ssl """ - cluster = TestCluster(ssl_options={'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version}) + cluster = TestCluster(ssl_context=create_ssl_context()) with pytest.raises(NoHostAvailable): cluster.connect() @@ -309,33 +324,25 @@ def test_cannot_connect_with_bad_client_auth(self): @test_category connection:ssl """ - ssl_options = {'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE} - - if not USES_PYOPENSSL: - # I don't set the bad certfile for pyopenssl because it hangs - ssl_options['certfile'] = DRIVER_CERTFILE_BAD - - cluster = TestCluster( - ssl_options={'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE} - ) + # A context without a client certificate cannot authenticate to this + # cluster. This covers the same handshake failure without relying on + # legacy ssl_options parsing. + cluster = TestCluster(ssl_context=create_ssl_context()) with pytest.raises(NoHostAvailable): cluster.connect() cluster.shutdown() def test_cannot_connect_with_invalid_hostname(self): - ssl_options = {'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version, - 'keyfile': DRIVER_KEYFILE, - 'certfile': DRIVER_CERTFILE} - ssl_options.update(verify_certs) - with pytest.raises(Exception): - validate_ssl_options(ssl_options=ssl_options, hostname='localhost') + validate_ssl_context( + create_ssl_context( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE, + hostname='localhost', + ), + hostname='localhost', + ) class SSLSocketErrorTests(unittest.TestCase): @@ -360,9 +367,7 @@ def test_ssl_want_write_errors_are_retried(self): @test_category connection:ssl """ - ssl_options = {'ca_certs': CLIENT_CA_CERTS, - 'ssl_version': ssl_version} - cluster = TestCluster(ssl_options=ssl_options) + cluster = TestCluster(ssl_context=create_ssl_context()) session = cluster.connect(wait_for_all_pools=True) try: session.execute('drop keyspace ssl_error_test') @@ -401,14 +406,7 @@ def test_can_connect_with_sslcontext_certificate(self): @test_category connection:ssl """ - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_context.verify_mode = ssl.CERT_REQUIRED - validate_ssl_options(ssl_context=ssl_context) + validate_ssl_context(create_ssl_context()) def test_can_connect_with_ssl_client_auth_password_private_key(self): """ @@ -421,75 +419,35 @@ def test_can_connect_with_ssl_client_auth_password_private_key(self): @test_category connection:ssl """ - abs_driver_keyfile = os.path.abspath(DRIVER_KEYFILE_ENCRYPTED) - abs_driver_certfile = os.path.abspath(DRIVER_CERTFILE) - ssl_options = {} - - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.use_certificate_file(abs_driver_certfile) - with open(abs_driver_keyfile) as keyfile: - key = crypto.load_privatekey(crypto.FILETYPE_PEM, keyfile.read(), b'cassandra') - ssl_context.use_privatekey(key) - ssl_context.set_verify(SSL.VERIFY_NONE, verify_callback) - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.load_cert_chain(certfile=abs_driver_certfile, - keyfile=abs_driver_keyfile, - password="cassandra") - ssl_context.verify_mode = ssl.CERT_NONE - validate_ssl_options(ssl_context=ssl_context, ssl_options=ssl_options) + validate_ssl_context(create_ssl_context( + certfile=os.path.abspath(DRIVER_CERTFILE), + keyfile=os.path.abspath(DRIVER_KEYFILE_ENCRYPTED), + password='cassandra', + )) def test_can_connect_with_ssl_context_ca_host_match(self): """ Test to validate that we are able to connect to a cluster using a SSLContext using client auth, an encrypted keyfile, and host matching """ - ssl_options = {} - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.use_certificate_file(DRIVER_CERTFILE) - with open(DRIVER_KEYFILE_ENCRYPTED) as keyfile: - key = crypto.load_privatekey(crypto.FILETYPE_PEM, keyfile.read(), b'cassandra') - ssl_context.use_privatekey(key) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_options["check_hostname"] = True - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_context.load_cert_chain( - certfile=DRIVER_CERTFILE, - keyfile=DRIVER_KEYFILE_ENCRYPTED, - password="cassandra", - ) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_options["check_hostname"] = True - validate_ssl_options(ssl_context=ssl_context, ssl_options=ssl_options) + validate_ssl_context(create_ssl_context( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE_ENCRYPTED, + password='cassandra', + hostname='127.0.0.1', + )) def test_cannot_connect_ssl_context_with_invalid_hostname(self): - ssl_options = {} - if USES_PYOPENSSL: - ssl_context = SSL.Context(SSL.TLS_CLIENT_METHOD) - ssl_context.use_certificate_file(DRIVER_CERTFILE) - with open(DRIVER_KEYFILE_ENCRYPTED) as keyfile: - key = crypto.load_privatekey(crypto.FILETYPE_PEM, keyfile.read(), b"cassandra") - ssl_context.use_privatekey(key) - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_options["check_hostname"] = True - else: - ssl_context = ssl.SSLContext(ssl_version) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_context.load_verify_locations(CLIENT_CA_CERTS) - ssl_context.load_cert_chain( - certfile=DRIVER_CERTFILE, - keyfile=DRIVER_KEYFILE_ENCRYPTED, - password="cassandra", - ) - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_options["check_hostname"] = True with pytest.raises(Exception): - validate_ssl_options(ssl_context=ssl_context, ssl_options=ssl_options, hostname="localhost") + validate_ssl_context( + create_ssl_context( + certfile=DRIVER_CERTFILE, + keyfile=DRIVER_KEYFILE_ENCRYPTED, + password='cassandra', + hostname='localhost', + ), + hostname='localhost', + ) @unittest.skipIf(USES_PYOPENSSL, "This test is for the built-in ssl.Context") def test_can_connect_with_sslcontext_default_context(self): @@ -499,4 +457,4 @@ def test_can_connect_with_sslcontext_default_context(self): @test_category connection:ssl """ ssl_context = ssl.create_default_context(cafile=CLIENT_CA_CERTS) - validate_ssl_options(ssl_context=ssl_context) + validate_ssl_context(ssl_context) diff --git a/tests/unit/test_client_routes.py b/tests/unit/test_client_routes.py index 0aa82fc76a..c0ac284ba4 100644 --- a/tests/unit/test_client_routes.py +++ b/tests/unit/test_client_routes.py @@ -437,7 +437,7 @@ def test_check_hostname_with_ssl_context_raises(self): self.assertIn("check_hostname", str(cm.exception)) def test_check_hostname_with_ssl_options_raises(self): - """Cluster should reject check_hostname=True in ssl_options with client_routes_config.""" + """Cluster should reject ssl_options before configuring client routes.""" config = ClientRoutesConfig( proxies=[ClientRouteProxy(str(uuid.uuid4()), "10.0.0.1")] ) @@ -447,7 +447,8 @@ def test_check_hostname_with_ssl_options_raises(self): ssl_options={'check_hostname': True}, client_routes_config=config, ) - self.assertIn("check_hostname", str(cm.exception)) + self.assertIn("ssl_options is deprecated", str(cm.exception)) + self.assertIn("ssl_context", str(cm.exception)) def test_disabled_check_hostname_with_client_routes_ok(self): """Cluster should allow check_hostname=False with client_routes_config.""" diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 3d55bc1860..f3e0b62f34 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -150,6 +150,33 @@ def test_backward_compat_positional(self): class ClusterTest(unittest.TestCase): + def test_ssl_options_is_rejected(self): + for ssl_options in ({}, {'ca_certs': '/path/to/ca.pem'}): + with self.subTest(ssl_options=ssl_options): + with self.assertRaisesRegex( + ValueError, + "ssl_options is deprecated.*ssl_context.*" + "ssl-options-migration"): + Cluster(ssl_options=ssl_options) + + def test_ssl_options_is_rejected_with_ssl_context(self): + with self.assertRaisesRegex( + ValueError, + "ssl_options is deprecated.*ssl_context.*" + "ssl-options-migration"): + Cluster(ssl_options={}, ssl_context=Mock()) + + def test_ssl_options_assignment_is_rejected(self): + cluster = Cluster() + + with self.assertRaisesRegex( + ValueError, + "ssl_options is deprecated.*ssl_context.*" + "ssl-options-migration"): + cluster.ssl_options = {'ca_certs': '/path/to/ca.pem'} + + assert cluster.ssl_options is None + def test_tuple_for_contact_points(self): cluster = Cluster(contact_points=[('localhost', 9045), ('127.0.0.2', 9046), '127.0.0.3'], port=9999) # Refactored for clarity