Skip to content

Commit a2e58d4

Browse files
committed
connection: address SSL review feedback
1 parent d77f7f0 commit a2e58d4

8 files changed

Lines changed: 210 additions & 35 deletions

File tree

cassandra/connection.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import errno
1818
from functools import wraps, partial, total_ordering
1919
from heapq import heappush, heappop
20+
import ipaddress
2021
import io
2122
import logging
2223
import socket
@@ -206,6 +207,93 @@ def _build_pyopenssl_context_from_options(ssl_module, ssl_options):
206207
return context
207208

208209

210+
def _normalized_hostname(hostname):
211+
return hostname.rstrip('.').lower()
212+
213+
214+
def _dnsname_match(dn, hostname):
215+
dn = _normalized_hostname(dn)
216+
hostname = _normalized_hostname(hostname)
217+
218+
if '*' not in dn:
219+
return dn == hostname
220+
221+
dn_labels = dn.split('.')
222+
hostname_labels = hostname.split('.')
223+
if (len(dn_labels) != len(hostname_labels) or
224+
not dn_labels or dn_labels[0] != '*' or
225+
any('*' in label for label in dn_labels[1:])):
226+
return False
227+
228+
return dn_labels[1:] == hostname_labels[1:]
229+
230+
231+
def _ipaddress_match(cert_ip, hostname):
232+
try:
233+
host_ip = ipaddress.ip_address(hostname)
234+
cert_ip = ipaddress.ip_address(cert_ip)
235+
except ValueError:
236+
return False
237+
return cert_ip == host_ip
238+
239+
240+
def _decode_x509_name(value):
241+
if isinstance(value, bytes):
242+
return value.decode('utf-8')
243+
return value
244+
245+
246+
def _pyopenssl_cert_subject_alt_names(cert):
247+
dns_names = []
248+
ip_addresses = []
249+
250+
for i in range(cert.get_extension_count()):
251+
extension = cert.get_extension(i)
252+
if extension.get_short_name() != b'subjectAltName':
253+
continue
254+
for item in str(extension).split(','):
255+
item = item.strip()
256+
if item.startswith('DNS:'):
257+
dns_names.append(item[4:])
258+
elif item.startswith('IP Address:'):
259+
ip_addresses.append(item[11:])
260+
261+
return dns_names, ip_addresses
262+
263+
264+
def _pyopenssl_cert_common_names(cert):
265+
return [
266+
_decode_x509_name(value)
267+
for key, value in cert.get_subject().get_components()
268+
if key == b'CN'
269+
]
270+
271+
272+
def _validate_pyopenssl_hostname(cert, hostname):
273+
san_dns_names, san_ip_addresses = _pyopenssl_cert_subject_alt_names(cert)
274+
san_names = san_dns_names + san_ip_addresses
275+
276+
for cert_ip in san_ip_addresses:
277+
if _ipaddress_match(cert_ip, hostname):
278+
return
279+
for cert_hostname in san_dns_names:
280+
if _dnsname_match(cert_hostname, hostname):
281+
return
282+
if san_names:
283+
raise ssl.CertificateError(
284+
"hostname %r doesn't match certificate subjectAltName %r" %
285+
(hostname, san_names))
286+
287+
common_names = _pyopenssl_cert_common_names(cert)
288+
for common_name in common_names:
289+
if _dnsname_match(common_name, hostname):
290+
return
291+
292+
raise ssl.CertificateError(
293+
"hostname %r doesn't match certificate commonName %r" %
294+
(hostname, common_names))
295+
296+
209297
class EndPoint(object):
210298
"""
211299
Represents the information to connect to a cassandra node.

cassandra/datastax/cloud/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ def _pyopenssl_context_from_cert(ca_cert_location, cert_location, key_location):
182182
from OpenSSL import SSL
183183
except ImportError as e:
184184
raise ImportError(
185-
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops")\
186-
.with_traceback(e.__traceback__)
185+
"PyOpenSSL must be installed to connect to Astra with the Eventlet or Twisted event loops"
186+
) from e
187187
ssl_context = SSL.Context(_default_pyopenssl_ssl_method(SSL))
188188
ssl_context.set_verify(SSL.VERIFY_PEER, callback=lambda _1, _2, _3, _4, ok: ok)
189189
ssl_context.use_certificate_file(cert_location)

cassandra/datastax/insights/reporter.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,21 @@ def _ssl_options_cert_validation_enabled(ssl_options):
4040
return bool(ssl_options.get('ca_certs') or ssl_options.get('check_hostname', False))
4141

4242

43+
def _safe_getattr(obj, name, default=None):
44+
try:
45+
return object.__getattribute__(obj, name)
46+
except AttributeError:
47+
return default
48+
49+
50+
def _ssl_context_cert_validation_enabled(ssl_context):
51+
if isinstance(ssl_context, ssl.SSLContext):
52+
return ssl_context.verify_mode == ssl.CERT_REQUIRED
53+
54+
from OpenSSL import SSL
55+
return ssl_context.get_verify_mode() != SSL.VERIFY_NONE
56+
57+
4358
class MonitorReporter(Thread):
4459

4560
def __init__(self, interval_sec, session):
@@ -146,16 +161,32 @@ def _get_startup_data(self):
146161
except AttributeError:
147162
compression_type = 'NONE'
148163

164+
connection = cc._connection
165+
connection_ssl_context = _safe_getattr(connection, 'ssl_context', None)
166+
connection_ssl_options = _safe_getattr(connection, 'ssl_options', None)
167+
endpoint = _safe_getattr(connection, 'endpoint', None)
168+
endpoint_ssl_options = _safe_getattr(endpoint, 'ssl_options', None)
169+
170+
ssl_context = (connection_ssl_context
171+
if connection_ssl_context is not None
172+
else self._session.cluster.ssl_context)
173+
ssl_options = (connection_ssl_options
174+
if connection_ssl_options is not None
175+
else self._session.cluster.ssl_options)
176+
ssl_options_explicit = bool(_safe_getattr(connection, '_ssl_options_explicit', False))
177+
ssl_enabled = (ssl_context is not None or
178+
ssl_options is not None or
179+
endpoint_ssl_options is not None or
180+
ssl_options_explicit)
181+
149182
cert_validation = None
150183
try:
151-
if self._session.cluster.ssl_context is not None:
152-
if isinstance(self._session.cluster.ssl_context, ssl.SSLContext):
153-
cert_validation = self._session.cluster.ssl_context.verify_mode == ssl.CERT_REQUIRED
154-
else: # pyopenssl
155-
from OpenSSL import SSL
156-
cert_validation = self._session.cluster.ssl_context.get_verify_mode() != SSL.VERIFY_NONE
157-
elif self._session.cluster.ssl_options is not None:
158-
cert_validation = _ssl_options_cert_validation_enabled(self._session.cluster.ssl_options)
184+
if ssl_context is not None:
185+
cert_validation = _ssl_context_cert_validation_enabled(ssl_context)
186+
elif ssl_options is not None:
187+
cert_validation = _ssl_options_cert_validation_enabled(ssl_options)
188+
elif endpoint_ssl_options is not None:
189+
cert_validation = _ssl_options_cert_validation_enabled(endpoint_ssl_options)
159190
except Exception as e:
160191
log.debug('Unable to get the cert validation: {}'.format(e))
161192

@@ -193,8 +224,7 @@ def _get_startup_data(self):
193224
'compression': compression_type.upper() if compression_type else 'NONE',
194225
'reconnectionPolicy': insights_registry.serialize(self._session.cluster.reconnection_policy),
195226
'sslConfigured': {
196-
'enabled': (self._session.cluster.ssl_context is not None or
197-
self._session.cluster.ssl_options is not None),
227+
'enabled': ssl_enabled,
198228
'certValidation': cert_validation
199229
},
200230
'authProvider': {

cassandra/io/eventletreactor.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525

2626
from cassandra.connection import (Connection, ConnectionShutdown, Timer, TimerManager,
2727
_build_pyopenssl_context_from_options as _build_pyopenssl_context,
28-
_default_pyopenssl_ssl_method)
28+
_default_pyopenssl_ssl_method,
29+
_validate_pyopenssl_hostname)
2930
try:
3031
from eventlet.green.OpenSSL import SSL
3132
_PYOPENSSL = True
@@ -102,7 +103,6 @@ def service_timeouts(cls):
102103

103104
def __init__(self, *args, **kwargs):
104105
Connection.__init__(self, *args, **kwargs)
105-
self.uses_legacy_ssl_options = False
106106
self._write_queue = Queue()
107107

108108
self._connect_socket()
@@ -121,21 +121,13 @@ def _wrap_socket_from_context(self):
121121
return self._socket
122122

123123
def _initiate_connection(self, sockaddr):
124-
if self.uses_legacy_ssl_options:
125-
super(EventletConnection, self)._initiate_connection(sockaddr)
126-
else:
127-
self._socket.connect(sockaddr)
128-
if self._ssl_enabled:
129-
self._socket.do_handshake()
124+
self._socket.connect(sockaddr)
125+
if self._ssl_enabled:
126+
self._socket.do_handshake()
130127

131128
def _validate_hostname(self):
132-
if self.uses_legacy_ssl_options:
133-
super(EventletConnection, self)._validate_hostname()
134-
else:
135-
cert_name = self._socket.get_peer_certificate().get_subject().commonName
136-
if cert_name != self.endpoint.address:
137-
raise Exception("Hostname verification failed! Certificate name '{}' "
138-
"doesn't endpoint '{}'".format(cert_name, self.endpoint.address))
129+
expected_name = (self.ssl_options or {}).get('server_hostname') or self.endpoint.address
130+
_validate_pyopenssl_hostname(self._socket.get_peer_certificate(), expected_name)
139131

140132
def _build_ssl_context_from_options(self):
141133
_check_pyopenssl()

cassandra/io/twistedreactor.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""
1818
import atexit
1919
import logging
20+
import ssl
2021
import time
2122
from functools import partial
2223
from threading import Thread, Lock
@@ -30,7 +31,8 @@
3031

3132
from cassandra.connection import (Connection, ConnectionShutdown, Timer, TimerManager, ConnectionException,
3233
_build_pyopenssl_context_from_options as _build_pyopenssl_context,
33-
_default_pyopenssl_ssl_method)
34+
_default_pyopenssl_ssl_method,
35+
_validate_pyopenssl_hostname)
3436

3537
try:
3638
from OpenSSL import SSL
@@ -174,9 +176,13 @@ def verify_callback(self, connection, x509, errnum, errdepth, ok):
174176

175177
def info_callback(self, connection, where, ret):
176178
if where & SSL.SSL_CB_HANDSHAKE_DONE:
177-
if self.check_hostname and self.endpoint.address != connection.get_peer_certificate().get_subject().commonName:
178-
transport = connection.get_app_data()
179-
transport.failVerification(Failure(ConnectionException("Hostname verification failed", self.endpoint)))
179+
if self.check_hostname:
180+
expected_name = self.ssl_options.get('server_hostname') or self.endpoint.address
181+
try:
182+
_validate_pyopenssl_hostname(connection.get_peer_certificate(), expected_name)
183+
except ssl.CertificateError as exc:
184+
transport = connection.get_app_data()
185+
transport.failVerification(Failure(ConnectionException(str(exc), self.endpoint)))
180186

181187
def clientConnectionForTLS(self, tlsProtocol):
182188
connection = SSL.Connection(self.context, None)

tests/unit/advanced/test_insights.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,21 @@ def superclass_sentinel_serializer(obj):
9595

9696
class TestMonitorReporterStartupData(unittest.TestCase):
9797

98-
def _get_startup_data(self, ssl_options=None, ssl_context=None):
98+
def _get_startup_data(self, ssl_options=None, ssl_context=None,
99+
connection_ssl_options=None, connection_ssl_context=None,
100+
connection_ssl_options_explicit=False):
99101
reporter = MonitorReporter.__new__(MonitorReporter)
100102
reporter._interval = 30
101103

102104
connection = Mock()
103105
connection.host = '127.0.0.1'
104106
connection._compression_type = 'NONE'
105107
connection._socket.getsockname.return_value = ('127.0.0.1', 9042)
108+
if connection_ssl_options is not None:
109+
connection.ssl_options = connection_ssl_options
110+
if connection_ssl_context is not None:
111+
connection.ssl_context = connection_ssl_context
112+
connection._ssl_options_explicit = connection_ssl_options_explicit
106113

107114
cluster = Mock()
108115
cluster.auth_provider = None
@@ -160,6 +167,13 @@ def test_ssl_context_reported_as_enabled(self):
160167

161168
assert startup_data['data']['sslConfigured']['enabled'] is True
162169

170+
def test_connection_ssl_options_reported_as_enabled(self):
171+
startup_data = self._get_startup_data(
172+
connection_ssl_options={}, connection_ssl_options_explicit=True)
173+
174+
assert startup_data['data']['sslConfigured']['enabled'] is True
175+
assert startup_data['data']['sslConfigured']['certValidation'] is False
176+
163177

164178
class TestConfigAsDict(unittest.TestCase):
165179

tests/unit/io/test_eventletreactor.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import unittest
1818
from types import SimpleNamespace
1919

20-
from unittest.mock import patch
20+
from unittest.mock import Mock, patch
2121

2222
from tests.unit.io.utils import TimerTestMixin
2323
from tests import notpypy, EVENT_LOOP_MANAGER
@@ -27,7 +27,9 @@
2727
from eventlet import monkey_patch
2828
from cassandra.io import eventletreactor
2929
from cassandra.io.eventletreactor import EventletConnection
30+
from OpenSSL import crypto
3031
except (ImportError, AttributeError):
32+
crypto = None
3133
eventletreactor = None
3234
EventletConnection = None # noqa
3335

@@ -37,6 +39,28 @@
3739
os.path.dirname(__file__), '..', '..', 'integration', 'long', 'ssl', 'rootCa.crt'))
3840

3941

42+
def _make_certificate(common_name, san_dns_names=None):
43+
key = crypto.PKey()
44+
key.generate_key(crypto.TYPE_RSA, 2048)
45+
46+
cert = crypto.X509()
47+
cert.get_subject().CN = common_name
48+
cert.set_serial_number(1)
49+
cert.gmtime_adj_notBefore(-3600)
50+
cert.gmtime_adj_notAfter(3600)
51+
cert.set_issuer(cert.get_subject())
52+
cert.set_pubkey(key)
53+
if san_dns_names:
54+
cert.add_extensions([
55+
crypto.X509Extension(
56+
b'subjectAltName',
57+
False,
58+
', '.join('DNS:%s' % name for name in san_dns_names).encode('ascii'))
59+
])
60+
cert.sign(key, 'sha256')
61+
return cert
62+
63+
4064
@unittest.skipIf(EventletConnection is None, "eventlet is not available")
4165
@unittest.skipIf(not getattr(eventletreactor, '_PYOPENSSL', False), "pyOpenSSL is not available")
4266
class EventletSSLContextTest(unittest.TestCase):
@@ -53,7 +77,7 @@ def test_empty_ssl_options_default_to_negotiating_tls(self):
5377
with patch.object(eventletreactor.SSL, 'Context') as context_mock:
5478
context = eventletreactor._build_pyopenssl_context_from_options({})
5579

56-
context_mock.assert_called_once_with(eventletreactor.SSL.TLS_CLIENT_METHOD)
80+
context_mock.assert_called_once_with(eventletreactor._default_ssl_method())
5781
assert context is context_mock.return_value
5882

5983
def test_default_ssl_method_falls_back_to_tls_method(self):
@@ -123,6 +147,27 @@ def test_wrap_socket_from_context_returns_wrapped_socket(self):
123147
wrapped_socket.set_connect_state.assert_called_once_with()
124148
assert conn._socket is wrapped_socket
125149

150+
def test_validate_hostname_uses_server_hostname_and_san(self):
151+
conn = EventletConnection.__new__(EventletConnection)
152+
conn.endpoint = DefaultEndPoint('10.0.0.1')
153+
conn.ssl_options = {'server_hostname': 'node.example.com'}
154+
conn._socket = Mock()
155+
conn._socket.get_peer_certificate.return_value = _make_certificate(
156+
'wrong.example.com', san_dns_names=['node.example.com'])
157+
158+
conn._validate_hostname()
159+
160+
def test_validate_hostname_prefers_san_over_common_name(self):
161+
conn = EventletConnection.__new__(EventletConnection)
162+
conn.endpoint = DefaultEndPoint('node.example.com')
163+
conn.ssl_options = {}
164+
conn._socket = Mock()
165+
conn._socket.get_peer_certificate.return_value = _make_certificate(
166+
'node.example.com', san_dns_names=['other.example.com'])
167+
168+
with self.assertRaises(ssl.CertificateError):
169+
conn._validate_hostname()
170+
126171

127172
skip_condition = EventletConnection is None or EVENT_LOOP_MANAGER != "eventlet"
128173
# There are some issues with some versions of pypy and eventlet

tests/unit/io/test_twistedreactor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_empty_ssl_options_default_to_negotiating_tls(self):
4444
with patch.object(twistedreactor.SSL, 'Context') as context_mock:
4545
context = twistedreactor._build_pyopenssl_context_from_options({})
4646

47-
context_mock.assert_called_once_with(twistedreactor.SSL.TLS_CLIENT_METHOD)
47+
context_mock.assert_called_once_with(twistedreactor._default_ssl_method())
4848
assert context is context_mock.return_value
4949

5050
def test_default_ssl_method_falls_back_to_tls_method(self):

0 commit comments

Comments
 (0)