Skip to content

ssl: improve pyOpenSSL hostname and context handling#941

Draft
dkropachev wants to merge 6 commits into
fix-empty-ssl-options-reactorsfrom
followup/pyopenssl-hostname-cert-loading
Draft

ssl: improve pyOpenSSL hostname and context handling#941
dkropachev wants to merge 6 commits into
fix-empty-ssl-options-reactorsfrom
followup/pyopenssl-hostname-cert-loading

Conversation

@dkropachev

@dkropachev dkropachev commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #938.

Why

#938 should stay focused on preserving explicit empty ssl_options={} as "SSL enabled". This PR contains the broader pyOpenSSL behavior changes so they can be reviewed separately.

What Changed

  • Adds SAN/IP/wildcard-aware hostname validation for pyOpenSSL-based reactors.
  • Uses server_hostname as the expected certificate hostname when SNI or cloud/private-link endpoints provide it.
  • Moves Twisted hostname verification callbacks onto each SSL.Connection instead of the shared context.
  • Centralizes pyOpenSSL context construction from ssl_options in cassandra.connection.
  • Translates stdlib ssl_version and cert_reqs values to pyOpenSSL equivalents while preserving pyOpenSSL-native values.
  • Improves cert/key loading parity with stdlib SSLContext, including cert chains and combined cert/key PEM files.
  • Applies cipher lists to pyOpenSSL contexts.
  • Builds cloud pyOpenSSL contexts when requested, including when the user supplies a custom stdlib ssl_context.
  • Allows client-routes validation to accept pyOpenSSL-style contexts without a check_hostname attribute.

Validation

  • uv run python -m py_compile cassandra/connection.py cassandra/io/eventletreactor.py cassandra/io/twistedreactor.py cassandra/datastax/cloud/__init__.py cassandra/cluster.py
  • uv run pytest -q tests/unit/test_connection.py tests/unit/test_cloud.py tests/unit/io/test_twistedreactor.py tests/unit/io/test_eventletreactor.py tests/unit/test_client_routes.py

Result: 116 passed, 2 skipped.

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.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces shared pyOpenSSL helpers for certificate parsing, hostname validation, TLS method selection, verification mapping, and SSL context construction. Cloud configuration and Eventlet/Twisted reactors now use these helpers. Hostname checks support SANs, wildcard DNS names, and exact IP matching. Tests cover TLS option handling, cloud context propagation, reactor certificate validation, and SSL contexts without check_hostname.

Sequence Diagram(s)

sequenceDiagram
  participant Connection
  participant Reactor
  participant pyOpenSSL
  Connection->>Reactor: provide shared TLS helpers
  Reactor->>pyOpenSSL: build context and perform handshake
  Reactor->>Connection: validate peer certificate hostname
Loading

Possibly related PRs

Suggested labels: area/Driver_-_python-driver

Suggested reviewers: lorak-mmk, sylwiaszunejko, nikagra

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main pyOpenSSL SSL context and hostname handling changes.
Description check ✅ Passed The description includes the motivation, key changes, and validation results, which covers the template’s core requirements.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cassandra/datastax/cloud/__init__.py (1)

79-94: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Consider declaring pyopenssl_context as an explicit class attribute.

CloudConfig only declares ssl_context = None (line 54), not pyopenssl_context, which is why getattr(config, 'pyopenssl_context', config.ssl_context) is needed here to avoid an AttributeError. If the pyOpenSSL context is ever not built while create_pyopenssl_context=True, this silently falls back to the stdlib ssl.SSLContext, which the Eventlet/Twisted reactors don't expect. Declaring pyopenssl_context = None explicitly would make the contract clearer than relying on getattr masking.

♻️ Suggested clarification
     host = None
     port = None
     keyspace = None
     local_dc = None
     ssl_context = None
+    pyopenssl_context = None
🤖 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/datastax/cloud/__init__.py` around lines 79 - 94, Declare
pyopenssl_context = None explicitly on the CloudConfig class alongside
ssl_context, then update get_cloud_config to access config.pyopenssl_context
directly when create_pyopenssl_context is enabled. Preserve the existing context
assignment behavior without using getattr as a fallback to the stdlib SSL
context.
🧹 Nitpick comments (1)
cassandra/io/twistedreactor.py (1)

163-164: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove dead verify_callback method.

This method is unused — context creation now always goes through _build_pyopenssl_context_from_options, which installs its own internal verify callback. Leaving this in place is misleading, since it looks like an active hostname/cert verification hook but has no effect.

♻️ Proposed removal
-    def verify_callback(self, connection, x509, errnum, errdepth, ok):
-        return ok
-
     def _hostname_to_verify(self):
🤖 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/io/twistedreactor.py` around lines 163 - 164, Remove the unused
verify_callback method from the relevant class, leaving
_build_pyopenssl_context_from_options as the sole context verification setup. Do
not alter the internal callback or other context behavior.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@cassandra/connection.py`:
- Around line 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.

---

Outside diff comments:
In `@cassandra/datastax/cloud/__init__.py`:
- Around line 79-94: Declare pyopenssl_context = None explicitly on the
CloudConfig class alongside ssl_context, then update get_cloud_config to access
config.pyopenssl_context directly when create_pyopenssl_context is enabled.
Preserve the existing context assignment behavior without using getattr as a
fallback to the stdlib SSL context.

---

Nitpick comments:
In `@cassandra/io/twistedreactor.py`:
- Around line 163-164: Remove the unused verify_callback method from the
relevant class, leaving _build_pyopenssl_context_from_options as the sole
context verification setup. Do not alter the internal callback or other context
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0d1d8ea0-6096-420a-bdc4-c9e3a142e1c7

📥 Commits

Reviewing files that changed from the base of the PR and between f8c3ca4 and 84771bf.

📒 Files selected for processing (10)
  • cassandra/cluster.py
  • cassandra/connection.py
  • cassandra/datastax/cloud/__init__.py
  • cassandra/io/eventletreactor.py
  • cassandra/io/twistedreactor.py
  • tests/unit/io/test_eventletreactor.py
  • tests/unit/io/test_twistedreactor.py
  • tests/unit/test_client_routes.py
  • tests/unit/test_cloud.py
  • tests/unit/test_connection.py

Comment thread cassandra/connection.py
Comment on lines +60 to +104
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)

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.

@dkropachev
dkropachev force-pushed the fix-empty-ssl-options-reactors branch 2 times, most recently from 9bcedb1 to d77f7f0 Compare July 22, 2026 00:51
@dkropachev
dkropachev marked this pull request as draft July 22, 2026 03:27
@dkropachev
dkropachev force-pushed the fix-empty-ssl-options-reactors branch 3 times, most recently from d05df6c to 3c6f515 Compare July 22, 2026 04:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant