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
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 34 additions & 40 deletions cassandra/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/backports.ssl_match_hostname/>`_.

.. 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
"""

Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 9256914. ssl_options is now a public migration-trap property whose setter rejects non-None assignment immediately. Cloud/SNI routing metadata is stored privately in _ssl_options and only that private value is forwarded to connections.

self._raise_ssl_options_migration_error()

# Handle port passed as string
if isinstance(port, str):
if not port.isdigit():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,))
Expand Down
134 changes: 119 additions & 15 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,124 @@ These docs will include some examples for how to achieve common configurations,
but the `ssl.SSLContext <https://docs.python.org/3/library/ssl.html#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 <https://docs.python.org/3/library/ssl.html#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
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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
Comment on lines 368 to +371
)
session = cluster.connect()

Expand Down
Loading
Loading