From 48046b2efd1085b793fab0ea6f90ad1611410a69 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Tue, 19 Feb 2013 15:42:18 +0000 Subject: [PATCH] SSL certificate verification PYTHON-466 --- pymongo/common.py | 37 ++++ pymongo/connection.py | 20 ++ pymongo/errors.py | 6 + pymongo/helpers.py | 6 +- pymongo/mongo_client.py | 53 ++++- pymongo/mongo_replica_set_client.py | 63 +++++- pymongo/pool.py | 66 ++++-- pymongo/replica_set_connection.py | 20 +- pymongo/ssl_match_hostname.py | 62 ++++++ setup.py | 2 + test/__init__.py | 9 +- test/certificates/ca.pem | 18 ++ test/certificates/client.pem | 32 +++ test/test_ssl.py | 299 ++++++++++++++++++++++++++-- 14 files changed, 650 insertions(+), 43 deletions(-) create mode 100644 pymongo/ssl_match_hostname.py create mode 100644 test/certificates/ca.pem create mode 100644 test/certificates/client.pem diff --git a/pymongo/common.py b/pymongo/common.py index 1bd3b36bb..06bb9412f 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -21,6 +21,12 @@ from pymongo.auth import MECHANISMS from pymongo.read_preferences import ReadPreference from pymongo.errors import ConfigurationError +HAS_SSL = True +try: + import ssl +except ImportError: + HAS_SSL = False + def raise_config_error(key, dummy): """Raise ConfigurationError with the given key name.""" @@ -63,6 +69,33 @@ def validate_positive_integer(option, value): return val +def validate_readable(option, value): + """Validates that 'value' is file-like and readable. + """ + # First make sure its a string py3.3 open(True, 'r') succeeds + # Used in ssl cert checking due to poor ssl module error reporting + value = validate_basestring(option, value) + open(value, 'r').close() + return value + + +def validate_cert_reqs(option, value): + """Validate the cert reqs are valid. It must be None or one of the three + values ``ssl.CERT_NONE``, ``ssl.CERT_OPTIONAL`` or ``ssl.CERT_REQUIRED``""" + if value is None: + return value + if HAS_SSL: + if value in (ssl.CERT_NONE, ssl.CERT_OPTIONAL, ssl.CERT_REQUIRED): + return value + raise ConfigurationError("The value of %s must be one of: " + "`ssl.CERT_NONE`, `ssl.CERT_OPTIONAL` or " + "`ssl.CERT_REQUIRED" % (option,)) + else: + raise ConfigurationError("The value of %s is set but can't be " + "validated. The ssl module is not available" + % (option,)) + + def validate_positive_integer_or_none(option, value): """Validate that 'value' is a positive integer or None. """ @@ -180,6 +213,10 @@ VALIDATORS = { 'connecttimeoutms': validate_timeout_or_none, 'sockettimeoutms': validate_timeout_or_none, 'ssl': validate_boolean, + 'ssl_keyfile': validate_readable, + 'ssl_certfile': validate_readable, + 'ssl_cert_reqs': validate_cert_reqs, + 'ssl_ca_certs': validate_readable, 'readpreference': validate_read_preference, 'read_preference': validate_read_preference, 'tag_sets': validate_tag_sets, diff --git a/pymongo/connection.py b/pymongo/connection.py index b1d1f3f20..a8b5b9fe9 100644 --- a/pymongo/connection.py +++ b/pymongo/connection.py @@ -135,8 +135,28 @@ class Connection(MongoClient): until :meth:`end_request()` - `slave_okay` or `slaveOk` (deprecated): Use `read_preference` instead. + - `ssl_keyfile`: The private keyfile used to identify the local + connection against mongod. If included with the ``certfile` then + only the ``ssl_certfile`` is needed. Implies ``ssl=True``. + - `ssl_certfile`: The certificate file used to identify the local + connection against mongod. Implies ``ssl=True``. + - `ssl_cert_reqs`: The parameter cert_reqs specifies whether a + certificate is required from the other side of the connection, + and whether it will be validated if provided. It must be one of the + three values ``ssl.CERT_NONE`` (certificates ignored), + ``ssl.CERT_OPTIONAL`` (not required, but validated if provided), or + ``ssl.CERT_REQUIRED`` (required and validated). If the value of + this parameter is not ``ssl.CERT_NONE``, then the ``ssl_ca_certs`` + parameter must point to a file of CA certificates. + Implies ``ssl=True``. + - `ssl_ca_certs`: The ca_certs file contains a set of concatenated + "certification authority" certificates, which are used to validate + certificates passed from the other end of the connection. + Implies ``ssl=True``. .. seealso:: :meth:`end_request` + .. versionchanged:: 2.4.2+ + Added addtional ssl options .. versionchanged:: 2.3 Added support for failover between mongos seed list members. .. versionchanged:: 2.2 diff --git a/pymongo/errors.py b/pymongo/errors.py index a52baad3a..63577befe 100644 --- a/pymongo/errors.py +++ b/pymongo/errors.py @@ -16,6 +16,11 @@ from bson.errors import * +try: + from ssl import CertificateError +except ImportError: + from pymongo.ssl_match_hostname import CertificateError + class PyMongoError(Exception): """Base class for all PyMongo exceptions. @@ -98,6 +103,7 @@ class InvalidURI(ConfigurationError): .. versionadded:: 1.5 """ + class UnsupportedOption(ConfigurationError): """Exception for unsupported options. diff --git a/pymongo/helpers.py b/pymongo/helpers.py index afff96dbf..bd9aa1908 100644 --- a/pymongo/helpers.py +++ b/pymongo/helpers.py @@ -26,6 +26,11 @@ from pymongo.errors import (AutoReconnect, OperationFailure, TimeoutError) +try: + from ssl import match_hostname +except ImportError: + from pymongo.ssl_match_hostname import match_hostname + def _index_list(key_or_list, direction=None): """Helper to generate a list of (key, direction) pairs. @@ -167,4 +172,3 @@ def shuffled(sequence): out = list(sequence) random.shuffle(out) return out - diff --git a/pymongo/mongo_client.py b/pymongo/mongo_client.py index 675c54c38..41e76b60e 100644 --- a/pymongo/mongo_client.py +++ b/pymongo/mongo_client.py @@ -49,6 +49,7 @@ from pymongo import (auth, message, pool, uri_parser) +from pymongo.common import HAS_SSL from pymongo.cursor_manager import CursorManager from pymongo.errors import (AutoReconnect, ConfigurationError, @@ -60,6 +61,7 @@ from pymongo.errors import (AutoReconnect, EMPTY = b("") + def _partition_node(node): """Split a host:port string returned from mongod/s into a (host, int(port)) pair needed for socket.connect(). @@ -163,11 +165,30 @@ class MongoClient(common.BaseObject): - `use_greenlets`: If ``True``, :meth:`start_request()` will ensure that the current greenlet uses the same socket for all operations until :meth:`end_request()` + - `ssl_keyfile`: The private keyfile used to identify the local + connection against mongod. If included with the ``certfile` then + only the ``ssl_certfile`` is needed. Implies ``ssl=True``. + - `ssl_certfile`: The certificate file used to identify the local + connection against mongod. Implies ``ssl=True``. + - `ssl_cert_reqs`: Specifies whether a certificate is required from + the other side of the connection, and whether it will be validated + if provided. It must be one of the three values ``ssl.CERT_NONE`` + (certificates ignored), ``ssl.CERT_OPTIONAL`` + (not required, but validated if provided), or ``ssl.CERT_REQUIRED`` + (required and validated). If the value of this parameter is not + ``ssl.CERT_NONE``, then the ``ssl_ca_certs`` parameter must point + to a file of CA certificates. Implies ``ssl=True``. + - `ssl_ca_certs`: The ca_certs file contains a set of concatenated + "certification authority" certificates, which are used to validate + certificates passed from the other end of the connection. + Implies ``ssl=True``. .. seealso:: :meth:`end_request` .. mongodoc:: connections + .. versionchanged:: 2.4.2+ + Added addtional ssl options .. versionadded:: 2.4 """ if host is None: @@ -232,13 +253,35 @@ class MongoClient(common.BaseObject): self.__net_timeout = options.get('sockettimeoutms') self.__conn_timeout = options.get('connecttimeoutms') - self.__use_ssl = options.get('ssl', False) - if self.__use_ssl and not pool.have_ssl: + self.__use_ssl = options.get('ssl', None) + self.__ssl_keyfile = options.get('ssl_keyfile', None) + self.__ssl_certfile = options.get('ssl_certfile', None) + self.__ssl_cert_reqs = options.get('ssl_cert_reqs', None) + self.__ssl_ca_certs = options.get('ssl_ca_certs', None) + + if self.__use_ssl and not HAS_SSL: raise ConfigurationError("The ssl module is not available. If you " "are using a python version previous to " "2.6 you must install the ssl package " "from PyPI.") + ssl_kwarg_keys = [k for k in kwargs.keys() if k.startswith('ssl_')] + if self.__use_ssl == False and ssl_kwarg_keys: + raise ConfigurationError("ssl has not been enabled but the " + "following ssl parameters have been set: " + "%s. Please set `ssl=True` or remove." + % ', '.join(ssl_kwarg_keys)) + + if self.__ssl_cert_reqs and not self.__ssl_ca_certs: + raise ConfigurationError("If `ssl_cert_reqs` is not " + "`ssl.CERT_NONE` then you must " + "include `ssl_ca_certs` to be able " + "to validate the server.") + + if ssl_kwarg_keys and self.__use_ssl is None: + # ssl options imply ssl = True + self.__use_ssl = True + self.__use_greenlets = options.get('use_greenlets', False) self.__pool = pool_class( None, @@ -246,7 +289,11 @@ class MongoClient(common.BaseObject): self.__net_timeout, self.__conn_timeout, self.__use_ssl, - use_greenlets=self.__use_greenlets) + use_greenlets=self.__use_greenlets, + ssl_keyfile=self.__ssl_keyfile, + ssl_certfile=self.__ssl_certfile, + ssl_cert_reqs=self.__ssl_cert_reqs, + ssl_ca_certs=self.__ssl_ca_certs) self.__document_class = document_class self.__tz_aware = common.validate_boolean('tz_aware', tz_aware) diff --git a/pymongo/mongo_replica_set_client.py b/pymongo/mongo_replica_set_client.py index 265cb228e..f63975268 100644 --- a/pymongo/mongo_replica_set_client.py +++ b/pymongo/mongo_replica_set_client.py @@ -31,6 +31,7 @@ attribute-style access: Database(MongoReplicaSetClient([u'...', u'...']), u'test_database') """ +import atexit import datetime import socket import struct @@ -38,7 +39,6 @@ import threading import time import warnings import weakref -import atexit from bson.py3compat import b from pymongo import (auth, @@ -58,6 +58,9 @@ from pymongo.errors import (AutoReconnect, InvalidDocument, OperationFailure) +if common.HAS_SSL: + import ssl + EMPTY = b("") MAX_BSON_SIZE = 4 * 1024 * 1024 MAX_RETRY = 3 @@ -369,7 +372,26 @@ class MongoReplicaSetClient(common.BaseObject): precedence. - `port`: For compatibility with :class:`~mongo_client.MongoClient`. The default port number to use for hosts. + - `ssl_keyfile`: The private keyfile used to identify the local + connection against mongod. If included with the ``certfile` then + only the ``ssl_certfile`` is needed. Implies ``ssl=True``. + - `ssl_certfile`: The certificate file used to identify the local + connection against mongod. Implies ``ssl=True``. + - `ssl_cert_reqs`: Specifies whether a certificate is required from + the other side of the connection, and whether it will be validated + if provided. It must be one of the three values ``ssl.CERT_NONE`` + (certificates ignored), ``ssl.CERT_OPTIONAL`` + (not required, but validated if provided), or ``ssl.CERT_REQUIRED`` + (required and validated). If the value of this parameter is not + ``ssl.CERT_NONE``, then the ``ssl_ca_certs`` parameter must point + to a file of CA certificates. Implies ``ssl=True``. + - `ssl_ca_certs`: The ca_certs file contains a set of concatenated + "certification authority" certificates, which are used to validate + certificates passed from the other end of the connection. + Implies ``ssl=True``. + .. versionchanged:: 2.4.2+ + Added addtional ssl options .. versionadded:: 2.4 """ self.__opts = {} @@ -437,16 +459,37 @@ class MongoReplicaSetClient(common.BaseObject): raise ConfigurationError("the replicaSet " "keyword parameter is required.") - self.__net_timeout = self.__opts.get('sockettimeoutms') self.__conn_timeout = self.__opts.get('connecttimeoutms') - self.__use_ssl = self.__opts.get('ssl', False) - if self.__use_ssl and not pool.have_ssl: + self.__use_ssl = self.__opts.get('ssl', None) + self.__ssl_keyfile = self.__opts.get('ssl_keyfile', None) + self.__ssl_certfile = self.__opts.get('ssl_certfile', None) + self.__ssl_cert_reqs = self.__opts.get('ssl_cert_reqs', None) + self.__ssl_ca_certs = self.__opts.get('ssl_ca_certs', None) + + if self.__use_ssl and not common.HAS_SSL: raise ConfigurationError("The ssl module is not available. If you " "are using a python version previous to " "2.6 you must install the ssl package " "from PyPI.") + ssl_kwarg_keys = [k for k in kwargs.keys() if k.startswith('ssl_')] + if self.__use_ssl == False and ssl_kwarg_keys: + raise ConfigurationError("ssl has not been enabled but the " + "following ssl parameters have been set: " + "%s. Please set `ssl=True` or remove." + % ', '.join(ssl_kwarg_keys)) + + if self.__ssl_cert_reqs and not self.__ssl_ca_certs: + raise ConfigurationError("If `ssl_cert_reqs` is not " + "`ssl.CERT_NONE` then you must " + "include `ssl_ca_certs` to be able " + "to validate the server.") + + if ssl_kwarg_keys and self.__use_ssl is None: + # ssl options imply ssl = True + self.__use_ssl = True + super(MongoReplicaSetClient, self).__init__(**self.__opts) if self.slave_okay: warnings.warn("slave_okay is deprecated. Please " @@ -717,8 +760,16 @@ class MongoReplicaSetClient(common.BaseObject): Returns (response, connection_pool, ping_time in seconds). """ connection_pool = self.pool_class( - host, self.__max_pool_size, self.__net_timeout, self.__conn_timeout, - self.__use_ssl, use_greenlets=self.__use_greenlets) + host, + self.__max_pool_size, + self.__net_timeout, + self.__conn_timeout, + self.__use_ssl, + use_greenlets=self.__use_greenlets, + ssl_keyfile=self.__ssl_keyfile, + ssl_certfile=self.__ssl_certfile, + ssl_cert_reqs=self.__ssl_cert_reqs, + ssl_ca_certs=self.__ssl_ca_certs) if self.in_request(): connection_pool.start_request() diff --git a/pymongo/pool.py b/pymongo/pool.py index 7014ed2a1..057222a00 100644 --- a/pymongo/pool.py +++ b/pymongo/pool.py @@ -20,19 +20,13 @@ import threading import weakref from pymongo import thread_util -from pymongo.errors import ConnectionFailure, ConfigurationError +from pymongo.common import HAS_SSL +from pymongo.errors import (CertificateError, ConnectionFailure, + ConfigurationError) +from pymongo.helpers import match_hostname - -have_ssl = True -try: +if HAS_SSL: import ssl -except ImportError: - have_ssl = False - - -NO_REQUEST = None -NO_SOCKET_YET = -1 - if sys.platform.startswith('java'): from select import cpython_compatible_select as select @@ -40,6 +34,10 @@ else: from select import select +NO_REQUEST = None +NO_SOCKET_YET = -1 + + def _closed(sock): """Return True if we know socket has been closed, False otherwise. """ @@ -96,7 +94,8 @@ class SocketInfo(object): # http://bugs.jython.org/issue1057 class Pool: def __init__(self, pair, max_size, net_timeout, conn_timeout, use_ssl, - use_greenlets): + use_greenlets, ssl_keyfile=None, ssl_certfile=None, + ssl_cert_reqs=None, ssl_ca_certs=None): """ :Parameters: - `pair`: a (hostname, port) tuple @@ -107,6 +106,23 @@ class Pool: - `use_greenlets`: bool, if True then start_request() assigns a socket to the current greenlet - otherwise it is assigned to the current thread + - `ssl_keyfile`: The private keyfile used to identify the local + connection against mongod. If included with the ``certfile` then + only the ``ssl_certfile`` is needed. Implies ``ssl=True``. + - `ssl_certfile`: The certificate file used to identify the local + connection against mongod. Implies ``ssl=True``. + - `ssl_cert_reqs`: Specifies whether a certificate is required from + the other side of the connection, and whether it will be validated + if provided. It must be one of the three values ``ssl.CERT_NONE`` + (certificates ignored), ``ssl.CERT_OPTIONAL`` + (not required, but validated if provided), or ``ssl.CERT_REQUIRED`` + (required and validated). If the value of this parameter is not + ``ssl.CERT_NONE``, then the ``ssl_ca_certs`` parameter must point + to a file of CA certificates. Implies ``ssl=True``. + - `ssl_ca_certs`: The ca_certs file contains a set of concatenated + "certification authority" certificates, which are used to validate + certificates passed from the other end of the connection. + Implies ``ssl=True``. """ if use_greenlets and not thread_util.have_greenlet: raise ConfigurationError( @@ -126,6 +142,14 @@ class Pool: self.net_timeout = net_timeout self.conn_timeout = conn_timeout self.use_ssl = use_ssl + self.ssl_keyfile = ssl_keyfile + self.ssl_certfile = ssl_certfile + self.ssl_cert_reqs = ssl_cert_reqs + self.ssl_ca_certs = ssl_ca_certs + + if HAS_SSL and use_ssl and not ssl_cert_reqs: + self.ssl_cert_reqs = ssl.CERT_NONE + self._ident = thread_util.create_ident(use_greenlets) # Map self._ident.get() -> request socket @@ -150,7 +174,8 @@ class Pool: finally: self.lock.release() - for sock_info in sockets: sock_info.close() + for sock_info in sockets: + sock_info.close() def create_connection(self, pair): """Connect to *pair* and return the socket object. @@ -211,17 +236,28 @@ class Pool: return_socket() when you're done with it. """ sock = self.create_connection(pair) + hostname = (pair or self.pair)[0] if self.use_ssl: try: - sock = ssl.wrap_socket(sock) + sock = ssl.wrap_socket(sock, + certfile=self.ssl_certfile, + keyfile=self.ssl_keyfile, + ca_certs=self.ssl_ca_certs, + cert_reqs=self.ssl_cert_reqs) + if self.ssl_cert_reqs: + try: + match_hostname(sock.getpeercert(), hostname) + except CertificateError, e: + raise ConnectionFailure("SSL certificate validation " + "failed: %s" % e) except ssl.SSLError: sock.close() raise ConnectionFailure("SSL handshake failed. MongoDB may " "not be configured with SSL support.") sock.settimeout(self.net_timeout) - return SocketInfo(sock, self.pool_id, pair and pair[0] or self.pair[0]) + return SocketInfo(sock, self.pool_id, hostname) def get_socket(self, pair=None): """Get a socket from the pool. diff --git a/pymongo/replica_set_connection.py b/pymongo/replica_set_connection.py index 5deb2053f..7b20b9a04 100644 --- a/pymongo/replica_set_connection.py +++ b/pymongo/replica_set_connection.py @@ -154,8 +154,26 @@ class ReplicaSetConnection(MongoReplicaSetClient): is no timeout. If both `network_timeout` and `socketTimeoutMS` are are specified `network_timeout` takes precedence, matching connection.Connection. + - `ssl_keyfile`: The private keyfile used to identify the local + connection against mongod. If included with the ``certfile` then + only the ``ssl_certfile`` is needed. Implies ``ssl=True``. + - `ssl_certfile`: The certificate file used to identify the local + connection against mongod. Implies ``ssl=True``. + - `ssl_cert_reqs`: Specifies whether a certificate is required from + the other side of the connection, and whether it will be validated + if provided. It must be one of the three values ``ssl.CERT_NONE`` + (certificates ignored), ``ssl.CERT_OPTIONAL`` + (not required, but validated if provided), or ``ssl.CERT_REQUIRED`` + (required and validated). If the value of this parameter is not + ``ssl.CERT_NONE``, then the ``ssl_ca_certs`` parameter must point + to a file of CA certificates. Implies ``ssl=True``. + - `ssl_ca_certs`: The ca_certs file contains a set of concatenated + "certification authority" certificates, which are used to validate + certificates passed from the other end of the connection. + Implies ``ssl=True``. - + .. versionchanged:: 2.4.2+ + Added addtional ssl options .. versionchanged:: 2.3 Added `tag_sets` and `secondary_acceptable_latency_ms` options. .. versionchanged:: 2.2 diff --git a/pymongo/ssl_match_hostname.py b/pymongo/ssl_match_hostname.py new file mode 100644 index 000000000..840f4d163 --- /dev/null +++ b/pymongo/ssl_match_hostname.py @@ -0,0 +1,62 @@ +# Backport of the match_hostname logic introduced in python 3.2 +# http://svn.python.org/projects/python/branches/release32-maint/Lib/ssl.py + +import re + + +class CertificateError(ValueError): + pass + + +def _dnsname_to_pat(dn): + pats = [] + for frag in dn.split(r'.'): + if frag == '*': + # When '*' is a fragment by itself, it matches a non-empty dotless + # fragment. + pats.append('[^.]+') + else: + # Otherwise, '*' matches any dotless fragment. + frag = re.escape(frag) + pats.append(frag.replace(r'\*', '[^.]*')) + return re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE) + + +def match_hostname(cert, hostname): + """Verify that *cert* (in decoded format as returned by + SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 rules + are mostly followed, but IP addresses are not accepted for *hostname*. + + CertificateError is raised on failure. On success, the function + returns nothing. + """ + if not cert: + raise ValueError("empty or no certificate") + dnsnames = [] + san = cert.get('subjectAltName', ()) + for key, value in san: + if key == 'DNS': + if _dnsname_to_pat(value).match(hostname): + return + dnsnames.append(value) + if not san: + # The subject is only checked when subjectAltName is empty + for sub in cert.get('subject', ()): + for key, value in sub: + # XXX according to RFC 2818, the most specific Common Name + # must be used. + if key == 'commonName': + if _dnsname_to_pat(value).match(hostname): + return + dnsnames.append(value) + if len(dnsnames) > 1: + raise CertificateError("hostname %r " + "doesn't match either of %s" + % (hostname, ', '.join(map(repr, dnsnames)))) + elif len(dnsnames) == 1: + raise CertificateError("hostname %r " + "doesn't match %r" + % (hostname, dnsnames[0])) + else: + raise CertificateError("no appropriate commonName or " + "subjectAltName fields were found") diff --git a/setup.py b/setup.py index bd06b7056..eb441d15e 100755 --- a/setup.py +++ b/setup.py @@ -259,6 +259,8 @@ if PY3: # are testing. # https://bitbucket.org/tarek/distribute/issue/233 extra_opts["packages"].append("test") + extra_opts['package_data'] = {"test": ["certificates/ca.pem", + "certificates/client.pem"]} # Hack to make "python3.x setup.py nosetests" work in python 3 # otherwise it won't run 2to3 before running the tests. if "nosetests" in sys.argv: diff --git a/test/__init__.py b/test/__init__.py index 4e8c97088..0b2835214 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -14,12 +14,17 @@ """Clean up databases after running `nosetests`. """ - +from pymongo.errors import ConnectionFailure from test.test_client import get_client def teardown(): - c = get_client() + try: + c = get_client() + except ConnectionFailure: + # Tests where ssl=True can cause connection failures here. + # Ignore and continue. + return c.drop_database("pymongo-pooling-tests") c.drop_database("pymongo_test") diff --git a/test/certificates/ca.pem b/test/certificates/ca.pem new file mode 100644 index 000000000..c0c893049 --- /dev/null +++ b/test/certificates/ca.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9DCCAl2gAwIBAgIJAJeYVdtunBOmMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYD +VQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxFjAUBgNVBAcMDU5ldyBZb3JrIENp +dHkxDjAMBgNVBAoMBTEwR2VuMQ8wDQYDVQQLDAZLZXJuZWwxGjAYBgNVBAMMEU15 +IENlcnQgQXV0aG9yaXR5MRswGQYJKoZIhvcNAQkBFgxyb290QGxhemFydXMwHhcN +MTIxMTI3MTkwMzM5WhcNMTMxMTI3MTkwMzM5WjCBkjELMAkGA1UEBhMCVVMxETAP +BgNVBAgMCE5ldyBZb3JrMRYwFAYDVQQHDA1OZXcgWW9yayBDaXR5MQ4wDAYDVQQK +DAUxMEdlbjEPMA0GA1UECwwGS2VybmVsMRowGAYDVQQDDBFNeSBDZXJ0IEF1dGhv +cml0eTEbMBkGCSqGSIb3DQEJARYMcm9vdEBsYXphcnVzMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQDXHKZ5j5T969S5C/Gm6f2ah7gaik3zRzWm2ZoAcz/U6fBq +rnha3bueXXBRWZ7d2HgN1a+JhjuYnffcdUSen9CFVxPiRCEgJmp2A8o90Kx5Bbcf +7zHobDOGs1EF3PQ2RKgXEOUjKZ/LZDbGhClsIYCD4SdFhRMqUcxc2lQMsWEaNwID +AQABo1AwTjAdBgNVHQ4EFgQUB0EZOp9+xbciTre81d/k/Am4ZBYwHwYDVR0jBBgw +FoAUB0EZOp9+xbciTre81d/k/Am4ZBYwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B +AQUFAAOBgQB6aSQNTmD4gIQEcZiOXHJVpGOHeHBOxWteMFhcBpWvt0Cv8sqLZIVq +x0eAC/tQFkAVEjT+T4S4UdtxgZ44RKCZPYI00qZsyz5bNoTE8kN/bmYNjyKMVFaG +1tU+elCdOstzBLjY1aHG1oQzbyqgoiSIDpfzjlyK/tBpckFGCz6c6A== +-----END CERTIFICATE----- diff --git a/test/certificates/client.pem b/test/certificates/client.pem new file mode 100644 index 000000000..d45079149 --- /dev/null +++ b/test/certificates/client.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIICkjCCAfsCCQCRlIP8LltShTANBgkqhkiG9w0BAQUFADCBkjELMAkGA1UEBhMC +VVMxETAPBgNVBAgMCE5ldyBZb3JrMRYwFAYDVQQHDA1OZXcgWW9yayBDaXR5MQ4w +DAYDVQQKDAUxMEdlbjEPMA0GA1UECwwGS2VybmVsMRowGAYDVQQDDBFNeSBDZXJ0 +IEF1dGhvcml0eTEbMBkGCSqGSIb3DQEJARYMcm9vdEBsYXphcnVzMB4XDTEyMTIx +MDE4NTEzN1oXDTEzMTIxMDE4NTEzN1owgYcxCzAJBgNVBAYTAlVTMREwDwYDVQQI +DAhOZXcgWW9yazEWMBQGA1UEBwwNTmV3IFlvcmsgQ2l0eTEOMAwGA1UECgwFMTBH +ZW4xDzANBgNVBAsMBktlcm5lbDEPMA0GA1UEAwwGY2xpZW50MRswGQYJKoZIhvcN +AQkBFgxyb290QGxhemFydXMwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALX6 +DqSWRJBEJJRIRqG5X3cFHzse5jGIdV8fTqikaVitvuhs15z1njzfqBQZMJBCEvNb +4eaenXJRMBDkEOcbfy6ah+ZLLqGFy7b6OxTROfx++3fTgsCAjBaIWvtGKNkwdcdM +7PQ2jE5bL8vN/ufbH2sX451nVd+j6oAz0dTz7RvhAgMBAAEwDQYJKoZIhvcNAQEF +BQADgYEAlOJmaiT3ZhUHfCgBQEjHUZ/mmMDbUrgq5ZfQSrW/r3c6u+k8s2LVqVut +Qz3V8z2vSuIkaPZRgDESWhPisi7sihhbV6xm4YTQW4LDlrom41/SEQ5TLP+Vz4Uq +avzrAdaQ6+zHbEB94TuWuE3vyWVIP0fT1PtzFjcOJUWzgjEIR7M= +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALX6DqSWRJBEJJRI +RqG5X3cFHzse5jGIdV8fTqikaVitvuhs15z1njzfqBQZMJBCEvNb4eaenXJRMBDk +EOcbfy6ah+ZLLqGFy7b6OxTROfx++3fTgsCAjBaIWvtGKNkwdcdM7PQ2jE5bL8vN +/ufbH2sX451nVd+j6oAz0dTz7RvhAgMBAAECgYEAmHRy+g5uSJLeNmBK1EiSIwtm +e8hKP+s7scJvyrdbDpEZJG2zQWtA82zIynXECsdgSwOKQQRXkaNU6oG3a3bM19uY +0CqFRb9EwOLIStp+CM5zLRGmUr73u/+JrBPUWWFJkJvINvTXt18CMnCmosTvygWB +IBZqsuEXQ6JcejxzQ6UCQQDdVUNdE2JgHp1qrr5l8563dztcrfCxuVFtgsj6qnhd +UrBAa388B9kn4yVAe2i55xFmtHsO9Bz3ViiDFO163SafAkEA0nq8PeZtcIlZ2c7+ +6/Vdw1uLE5APVG2H9VEZdaVvkwIIXo8WQfMwWo5MQyPjVyBhUGlDwnKa46AcuplJ +2XMtfwJBAIDrMfKb4Ng13OEP6Yz+yvr4MxZ3plQOqlRMMn53HubUzB6pvpGbzKwE +DWWyvDxUT/lvtKHwJJMYlz5KyUygVecCQHr50RBNmLW+2muDILiWlOD2lIyqh/pp +QJ2Zc8mkDkuTTXaKHZQM1byjFXXI+yRFu/Xyeu+abFsAiqiPtXFCdVsCQHai+Ykv +H3y0mUJmwBVP2fBE3GiTGlaadM0auZKu7/ad+yo7Hv8Kibacwibzrj9PjT3mFSSF +vujX1oWOaxAMVbE= +-----END PRIVATE KEY----- diff --git a/test/test_ssl.py b/test/test_ssl.py index 3bf1905f5..30f07d6dd 100644 --- a/test/test_ssl.py +++ b/test/test_ssl.py @@ -14,20 +14,38 @@ """Tests for SSL support.""" -import unittest +import os +import socket import sys +import unittest sys.path[0:0] = [""] from nose.plugins.skip import SkipTest from pymongo import MongoClient, MongoReplicaSetClient +from pymongo.common import HAS_SSL from pymongo.errors import ConfigurationError, ConnectionFailure -have_ssl = True -try: +if HAS_SSL: import ssl -except ImportError: - have_ssl = False + +CERT_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), + 'certificates') +CLIENT_PEM = os.path.join(CERT_PATH, 'client.pem') +CA_PEM = os.path.join(CERT_PATH, 'ca.pem') + + +def has_server_host_entry(): + """Returns True if 'server' is resolvable.""" + socket_timeout = socket.getdefaulttimeout() + socket.setdefaulttimeout(1) + try: + socket.gethostbyname('server') + has_server_host_entry = True + except: + has_server_host_entry = False + socket.setdefaulttimeout(socket_timeout) + return has_server_host_entry class TestSSL(unittest.TestCase): @@ -37,19 +55,91 @@ class TestSSL(unittest.TestCase): raise SkipTest("Python 3.0.x has problems " "with SSL and socket timeouts.") + # MongoDB not configured for SSL? + try: + MongoClient(connectTimeoutMS=100, ssl=True) + self.simple_ssl = True + except ConnectionFailure: + self.simple_ssl = False + + # MongoDB configured with server.pem, ca.pem and crl.pem from + # mongodb jstests/lib + try: + MongoClient(connectTimeoutMS=100, ssl=True, + ssl_certfile=CLIENT_PEM) + self.cert_ssl = True + except ConnectionFailure: + self.cert_ssl = False + def test_config_ssl(self): + """Tests various ssl configurations""" self.assertRaises(ConfigurationError, MongoClient, ssl='foo') + self.assertRaises(ConfigurationError, + MongoClient, + ssl=False, + ssl_certfile=CLIENT_PEM) self.assertRaises(TypeError, MongoClient, ssl=0) self.assertRaises(TypeError, MongoClient, ssl=5.5) self.assertRaises(TypeError, MongoClient, ssl=[]) self.assertRaises(ConfigurationError, MongoReplicaSetClient, ssl='foo') + self.assertRaises(ConfigurationError, + MongoReplicaSetClient, + ssl=False, + ssl_certfile=CLIENT_PEM) self.assertRaises(TypeError, MongoReplicaSetClient, ssl=0) self.assertRaises(TypeError, MongoReplicaSetClient, ssl=5.5) self.assertRaises(TypeError, MongoReplicaSetClient, ssl=[]) + self.assertRaises(IOError, MongoClient, ssl_certfile="NoSuchFile") + self.assertRaises(TypeError, MongoClient, ssl_certfile=True) + self.assertRaises(TypeError, MongoClient, ssl_certfile=[]) + self.assertRaises(IOError, MongoClient, ssl_keyfile="NoSuchFile") + self.assertRaises(TypeError, MongoClient, ssl_keyfile=True) + self.assertRaises(TypeError, MongoClient, ssl_keyfile=[]) + + self.assertRaises(IOError, + MongoReplicaSetClient, + ssl_keyfile="NoSuchFile") + self.assertRaises(IOError, + MongoReplicaSetClient, + ssl_certfile="NoSuchFile") + self.assertRaises(TypeError, MongoReplicaSetClient, ssl_certfile=True) + + # Test invalid combinations + self.assertRaises(ConfigurationError, + MongoClient, + ssl=False, + ssl_keyfile=CLIENT_PEM) + self.assertRaises(ConfigurationError, + MongoClient, + ssl=False, + ssl_certfile=CLIENT_PEM) + self.assertRaises(ConfigurationError, + MongoClient, + ssl=False, + ssl_keyfile=CLIENT_PEM, + ssl_certfile=CLIENT_PEM) + + self.assertRaises(ConfigurationError, + MongoReplicaSetClient, + ssl=False, + ssl_keyfile=CLIENT_PEM) + self.assertRaises(ConfigurationError, + MongoReplicaSetClient, + ssl=False, + ssl_certfile=CLIENT_PEM) + self.assertRaises(ConfigurationError, + MongoReplicaSetClient, + ssl=False, + ssl_keyfile=CLIENT_PEM, + ssl_certfile=CLIENT_PEM) + def test_no_ssl(self): - if have_ssl: + # Tests what happens when ssl is off on the server but you try to + # connect to mongodb with ssl=True + + if HAS_SSL: raise SkipTest( "The ssl module is available, can't test what happens " "without it." @@ -60,26 +150,205 @@ class TestSSL(unittest.TestCase): self.assertRaises(ConfigurationError, MongoReplicaSetClient, ssl=True) - def test_simple_ops(self): - if not have_ssl: + def test_simple_ssl(self): + # Expects the server to be running with ssl and with + # no --sslPEMKeyFile or with --sslWeakCertificateValidation + + if not HAS_SSL: raise SkipTest("The ssl module is not available.") - try: - client = MongoClient(connectTimeoutMS=100, ssl=True) - # MongoDB not configured for SSL? - except ConnectionFailure: - raise SkipTest("No mongod available over SSL") + if not self.simple_ssl: + raise SkipTest("No simple mongod available over SSL") + + client = MongoClient(ssl=True) response = client.admin.command('ismaster') if 'setName' in response: client = MongoReplicaSetClient(replicaSet=response['setName'], - w=len(response['hosts']), - ssl=True) + w=len(response['hosts']), + ssl=True) db = client.pymongo_ssl_test db.test.drop() self.assertTrue(db.test.insert({'ssl': True})) self.assertTrue(db.test.find_one()['ssl']) + def test_cert_ssl(self): + # Expects the server to be running with the the server.pem, ca.pem + # and crl.pem provided in mongodb and the server tests eg: + # + # --sslPEMKeyFile=jstests/libs/server.pem + # --sslCAFile=jstests/libs/ca.pem + # --sslCRLFile=jstests/libs/crl.pem + # + # Also requires an /etc/hosts entry where "server" is resolvable + + if not HAS_SSL: + raise SkipTest("The ssl module is not available.") + + if not self.cert_ssl: + raise SkipTest("No mongod available over SSL with certs") + + client = MongoClient(ssl=True, ssl_certfile=CLIENT_PEM) + response = client.admin.command('ismaster') + if 'setName' in response: + client = MongoReplicaSetClient(replicaSet=response['setName'], + w=len(response['hosts']), + ssl=True, ssl_certfile=CLIENT_PEM) + + db = client.pymongo_ssl_test + db.test.drop() + self.assertTrue(db.test.insert({'ssl': True})) + self.assertTrue(db.test.find_one()['ssl']) + + def test_cert_ssl_implicitly_set(self): + # Expects the server to be running with the the server.pem, ca.pem + # and crl.pem provided in mongodb and the server tests eg: + # + # --sslPEMKeyFile=jstests/libs/server.pem + # --sslCAFile=jstests/libs/ca.pem + # --sslCRLFile=jstests/libs/crl.pem + # + # Also requires an /etc/hosts entry where "server" is resolvable + + if not HAS_SSL: + raise SkipTest("The ssl module is not available.") + + if not self.cert_ssl: + raise SkipTest("No mongod available over SSL with certs") + + client = MongoClient(ssl_certfile=CLIENT_PEM) + response = client.admin.command('ismaster') + if 'setName' in response: + client = MongoReplicaSetClient(replicaSet=response['setName'], + w=len(response['hosts']), + ssl_certfile=CLIENT_PEM) + + db = client.pymongo_ssl_test + db.test.drop() + self.assertTrue(db.test.insert({'ssl': True})) + self.assertTrue(db.test.find_one()['ssl']) + + def test_cert_ssl_validation(self): + # Expects the server to be running with the the server.pem, ca.pem + # and crl.pem provided in mongodb and the server tests eg: + # + # --sslPEMKeyFile=jstests/libs/server.pem + # --sslCAFile=jstests/libs/ca.pem + # --sslCRLFile=jstests/libs/crl.pem + # + # Also requires an /etc/hosts entry where "server" is resolvable + + if not HAS_SSL: + raise SkipTest("The ssl module is not available.") + + if not self.cert_ssl: + raise SkipTest("No mongod available over SSL with certs") + + if not has_server_host_entry(): + raise SkipTest("No hosts entry for 'server' cannot validate " + "hostname in the certificate") + + client = MongoClient('server', + ssl=True, + ssl_certfile=CLIENT_PEM, + ssl_cert_reqs=ssl.CERT_REQUIRED, + ssl_ca_certs=CA_PEM) + response = client.admin.command('ismaster') + if 'setName' in response: + client = MongoReplicaSetClient('server', + replicaSet=response['setName'], + w=len(response['hosts']), + ssl=True, + ssl_certfile=CLIENT_PEM, + ssl_cert_reqs=ssl.CERT_REQUIRED, + ssl_ca_certs=CA_PEM) + + db = client.pymongo_ssl_test + db.test.drop() + self.assertTrue(db.test.insert({'ssl': True})) + self.assertTrue(db.test.find_one()['ssl']) + + def test_cert_ssl_validation_optional(self): + # Expects the server to be running with the the server.pem, ca.pem + # and crl.pem provided in mongodb and the server tests eg: + # + # --sslPEMKeyFile=jstests/libs/server.pem + # --sslCAFile=jstests/libs/ca.pem + # --sslCRLFile=jstests/libs/crl.pem + # + # Also requires an /etc/hosts entry where "server" is resolvable + + if not HAS_SSL: + raise SkipTest("The ssl module is not available.") + + if not self.cert_ssl: + raise SkipTest("No mongod available over SSL with certs") + + if not has_server_host_entry(): + raise SkipTest("No hosts entry for 'server' cannot validate " + "hostname in the certificate") + + client = MongoClient('server', + ssl=True, + ssl_certfile=CLIENT_PEM, + ssl_cert_reqs=ssl.CERT_OPTIONAL, + ssl_ca_certs=CA_PEM) + response = client.admin.command('ismaster') + if 'setName' in response: + client = MongoReplicaSetClient('server', + replicaSet=response['setName'], + w=len(response['hosts']), + ssl=True, + ssl_certfile=CLIENT_PEM, + ssl_cert_reqs=ssl.CERT_OPTIONAL, + ssl_ca_certs=CA_PEM) + + db = client.pymongo_ssl_test + db.test.drop() + self.assertTrue(db.test.insert({'ssl': True})) + self.assertTrue(db.test.find_one()['ssl']) + + def test_cert_ssl_validation_hostname_fail(self): + # Expects the server to be running with the the server.pem, ca.pem + # and crl.pem provided in mongodb and the server tests eg: + # + # --sslPEMKeyFile=jstests/libs/server.pem + # --sslCAFile=jstests/libs/ca.pem + # --sslCRLFile=jstests/libs/crl.pem + + if not HAS_SSL: + raise SkipTest("The ssl module is not available.") + + if not self.cert_ssl: + raise SkipTest("No mongod available over SSL with certs") + + client = MongoClient(ssl=True, ssl_certfile=CLIENT_PEM) + response = client.admin.command('ismaster') + singleServer = 'setName' not in response + + if singleServer: + try: + MongoClient('localhost', + ssl=True, + ssl_certfile=CLIENT_PEM, + ssl_cert_reqs=ssl.CERT_REQUIRED, + ssl_ca_certs=CA_PEM) + self.fail("Invalid hostname should have failed") + except: + pass + else: + try: + MongoReplicaSetClient('localhost', + replicaSet=response['setName'], + w=len(response['hosts']), + ssl=True, + ssl_certfile=CLIENT_PEM, + ssl_cert_reqs=ssl.CERT_OPTIONAL, + ssl_ca_certs=CA_PEM) + self.fail("Invalid hostname should have failed") + except: + pass + if __name__ == "__main__": unittest.main()