diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 08c0dbbf36..8ba16273de 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -620,17 +620,19 @@ buildvariants: - macos-14 batchtime: 10080 expansions: + TEST_NAME: default SUB_TEST_NAME: pyopenssl PYTHON_BINARY: /Library/Frameworks/Python.Framework/Versions/3.9/bin/python3 - name: pyopenssl-rhel8-python3.10 tasks: - - name: .replica_set .auth .ssl .sync - - name: .7.0 .auth .ssl .sync + - name: .replica_set .auth .ssl .sync_async + - name: .7.0 .auth .ssl .sync_async display_name: PyOpenSSL RHEL8 Python3.10 run_on: - rhel87-small batchtime: 10080 expansions: + TEST_NAME: default SUB_TEST_NAME: pyopenssl PYTHON_BINARY: /opt/python/3.10/bin/python3 - name: pyopenssl-rhel8-python3.11 @@ -642,6 +644,7 @@ buildvariants: - rhel87-small batchtime: 10080 expansions: + TEST_NAME: default SUB_TEST_NAME: pyopenssl PYTHON_BINARY: /opt/python/3.11/bin/python3 - name: pyopenssl-rhel8-python3.12 @@ -653,17 +656,19 @@ buildvariants: - rhel87-small batchtime: 10080 expansions: + TEST_NAME: default SUB_TEST_NAME: pyopenssl PYTHON_BINARY: /opt/python/3.12/bin/python3 - name: pyopenssl-win64-python3.13 tasks: - - name: .replica_set .auth .ssl .sync - - name: .7.0 .auth .ssl .sync + - name: .replica_set .auth .ssl .sync_async + - name: .7.0 .auth .ssl .sync_async display_name: PyOpenSSL Win64 Python3.13 run_on: - windows-64-vsMulti-small batchtime: 10080 expansions: + TEST_NAME: default SUB_TEST_NAME: pyopenssl PYTHON_BINARY: C:/python/Python313/python.exe - name: pyopenssl-rhel8-pypy3.10 @@ -675,6 +680,7 @@ buildvariants: - rhel87-small batchtime: 10080 expansions: + TEST_NAME: default SUB_TEST_NAME: pyopenssl PYTHON_BINARY: /opt/python/pypy3.10/bin/python3 diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 7b88be85b7..be1a960db2 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -250,7 +250,7 @@ def create_enterprise_auth_variants(): def create_pyopenssl_variants(): base_name = "PyOpenSSL" batchtime = BATCHTIME_WEEK - expansions = dict(SUB_TEST_NAME="pyopenssl") + expansions = dict(TEST_NAME="default", SUB_TEST_NAME="pyopenssl") variants = [] for python in ALL_PYTHONS: @@ -265,14 +265,25 @@ def create_pyopenssl_variants(): host = DEFAULT_HOST display_name = get_variant_name(base_name, host, python=python) - variant = create_variant( - [f".replica_set .{auth} .{ssl} .sync", f".7.0 .{auth} .{ssl} .sync"], - display_name, - python=python, - host=host, - expansions=expansions, - batchtime=batchtime, - ) + # only need to run some on async + if python in (CPYTHONS[1], CPYTHONS[-1]): + variant = create_variant( + [f".replica_set .{auth} .{ssl} .sync_async", f".7.0 .{auth} .{ssl} .sync_async"], + display_name, + python=python, + host=host, + expansions=expansions, + batchtime=batchtime, + ) + else: + variant = create_variant( + [f".replica_set .{auth} .{ssl} .sync", f".7.0 .{auth} .{ssl} .sync"], + display_name, + python=python, + host=host, + expansions=expansions, + batchtime=batchtime, + ) variants.append(variant) return variants diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 369e688b1e..a9117ec57a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -421,3 +421,21 @@ partially-converted asynchronous version of the same name to the `test/asynchron Use this generated file as a starting point for the completed conversion. The script is used like so: `python tools/convert_test_to_async.py [test_file.py]` + +## Running PyMongo with SSL +Note that `AsyncMongoClient` does not support PyOpenSSL. +Assuming all required packages are installed, set the `tls` and `tlsAllowInvalidCertificates` flags in the URI to enable +the driver to connect with SSL, like so: +```python +from pymongo import MongoClient + +client = MongoClient( + "mongodb://localhost:27017?tls=true&tlsAllowInvalidCertificates=true" +) +``` +Another way of doing this would be to pass these options in as parameters to the MongoClient, like so: +```python +client = MongoClient( + "mongodb://localhost:27017", tls=True, tlsAllowInvalidCertificates=True +) +``` diff --git a/doc/changelog.rst b/doc/changelog.rst index 46e7364f53..df4e432987 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -18,6 +18,8 @@ Version 4.12.1 is a bug fix release. Eventlet is actively being sunset by its maintainers and has compatibility issues with PyMongo's dnspython dependency. - Fixed a bug where MongoDB cluster topology changes could cause asynchronous operations to take much longer to complete due to holding the Topology lock while closing stale connections. +- Fixed a bug that would cause AsyncMongoClient to attempt to use PyOpenSSL when available, resulting in errors such as + "pymongo.errors.ServerSelectionTimeoutError: 'SSLContext' object has no attribute 'wrap_bio'" Issues Resolved ............... diff --git a/pymongo/asynchronous/encryption.py b/pymongo/asynchronous/encryption.py index 71a694a619..f97ab3b605 100644 --- a/pymongo/asynchronous/encryption.py +++ b/pymongo/asynchronous/encryption.py @@ -87,7 +87,7 @@ from pymongo.results import BulkWriteResult, DeleteResult from pymongo.ssl_support import BLOCKING_IO_ERRORS, get_ssl_context from pymongo.typings import _DocumentType, _DocumentTypeArg -from pymongo.uri_parser_shared import parse_host +from pymongo.uri_parser_shared import _parse_kms_tls_options, parse_host from pymongo.write_concern import WriteConcern if TYPE_CHECKING: @@ -157,6 +157,7 @@ def __init__( self.mongocryptd_client = mongocryptd_client self.opts = opts self._spawned = False + self._kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) async def kms_request(self, kms_context: MongoCryptKmsContext) -> None: """Complete a KMS request. @@ -168,7 +169,7 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None: endpoint = kms_context.endpoint message = kms_context.message provider = kms_context.kms_provider - ctx = self.opts._kms_ssl_contexts.get(provider) + ctx = self._kms_ssl_contexts.get(provider) if ctx is None: # Enable strict certificate verification, OCSP, match hostname, and # SNI using the system default CA certificates. @@ -180,6 +181,7 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None: False, # allow_invalid_certificates False, # allow_invalid_hostnames False, # disable_ocsp_endpoint_check + _IS_SYNC, ) # CSOT: set timeout for socket creation. connect_timeout = max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0.001) @@ -675,6 +677,7 @@ def __init__( kms_tls_options=kms_tls_options, key_expiration_ms=key_expiration_ms, ) + self._kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) self._io_callbacks: Optional[_EncryptionIO] = _EncryptionIO( None, key_vault_coll, None, opts ) diff --git a/pymongo/asynchronous/pool.py b/pymongo/asynchronous/pool.py index 8b18ab927b..bc8952ab57 100644 --- a/pymongo/asynchronous/pool.py +++ b/pymongo/asynchronous/pool.py @@ -86,7 +86,7 @@ from pymongo.server_api import _add_to_command from pymongo.server_type import SERVER_TYPE from pymongo.socket_checker import SocketChecker -from pymongo.ssl_support import SSLError +from pymongo.ssl_support import PYSSLError, SSLError if TYPE_CHECKING: from bson import CodecOptions @@ -638,7 +638,7 @@ async def _raise_connection_failure(self, error: BaseException) -> NoReturn: reason = ConnectionClosedReason.ERROR await self.close_conn(reason) # SSLError from PyOpenSSL inherits directly from Exception. - if isinstance(error, (IOError, OSError, SSLError)): + if isinstance(error, (IOError, OSError, SSLError, PYSSLError)): details = _get_timeout_details(self.opts) _raise_connection_failure(self.address, error, timeout_details=details) else: @@ -1052,7 +1052,7 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR), error=ConnectionClosedReason.ERROR, ) - if isinstance(error, (IOError, OSError, SSLError)): + if isinstance(error, (IOError, OSError, SSLError, PYSSLError)): details = _get_timeout_details(self.opts) _raise_connection_failure(self.address, error, timeout_details=details) diff --git a/pymongo/client_options.py b/pymongo/client_options.py index a66e87c9f6..bd27dd4eb0 100644 --- a/pymongo/client_options.py +++ b/pymongo/client_options.py @@ -84,7 +84,9 @@ def _parse_read_concern(options: Mapping[str, Any]) -> ReadConcern: return ReadConcern(concern) -def _parse_ssl_options(options: Mapping[str, Any]) -> tuple[Optional[SSLContext], bool]: +def _parse_ssl_options( + options: Mapping[str, Any], is_sync: bool +) -> tuple[Optional[SSLContext], bool]: """Parse ssl options.""" use_tls = options.get("tls") if use_tls is not None: @@ -138,6 +140,7 @@ def _parse_ssl_options(options: Mapping[str, Any]) -> tuple[Optional[SSLContext] allow_invalid_certificates, allow_invalid_hostnames, disable_ocsp_endpoint_check, + is_sync, ) return ctx, allow_invalid_hostnames return None, allow_invalid_hostnames @@ -167,7 +170,7 @@ def _parse_pool_options( compression_settings = CompressionSettings( options.get("compressors", []), options.get("zlibcompressionlevel", -1) ) - ssl_context, tls_allow_invalid_hostnames = _parse_ssl_options(options) + ssl_context, tls_allow_invalid_hostnames = _parse_ssl_options(options, is_sync) load_balanced = options.get("loadbalanced") max_connecting = options.get("maxconnecting", common.MAX_CONNECTING) return PoolOptions( diff --git a/pymongo/encryption_options.py b/pymongo/encryption_options.py index 4cb94cba30..721e9ce31f 100644 --- a/pymongo/encryption_options.py +++ b/pymongo/encryption_options.py @@ -32,7 +32,6 @@ from bson import int64 from pymongo.common import validate_is_mapping from pymongo.errors import ConfigurationError -from pymongo.uri_parser_shared import _parse_kms_tls_options if TYPE_CHECKING: from pymongo.typings import _AgnosticMongoClient, _DocumentTypeArg @@ -236,7 +235,7 @@ def __init__( if not any("idleShutdownTimeoutSecs" in s for s in self._mongocryptd_spawn_args): self._mongocryptd_spawn_args.append("--idleShutdownTimeoutSecs=60") # Maps KMS provider name to a SSLContext. - self._kms_ssl_contexts = _parse_kms_tls_options(kms_tls_options) + self._kms_tls_options = kms_tls_options self._bypass_query_analysis = bypass_query_analysis self._key_expiration_ms = key_expiration_ms diff --git a/pymongo/network_layer.py b/pymongo/network_layer.py index e287655c61..243e6aaa55 100644 --- a/pymongo/network_layer.py +++ b/pymongo/network_layer.py @@ -46,22 +46,18 @@ _HAVE_SSL = False try: - from pymongo.pyopenssl_context import ( - BLOCKING_IO_LOOKUP_ERROR, - BLOCKING_IO_READ_ERROR, - BLOCKING_IO_WRITE_ERROR, - _sslConn, - ) + from pymongo.pyopenssl_context import _sslConn _HAVE_PYOPENSSL = True except ImportError: _HAVE_PYOPENSSL = False - _sslConn = SSLSocket # type: ignore - from pymongo.ssl_support import ( # type: ignore[assignment] - BLOCKING_IO_LOOKUP_ERROR, - BLOCKING_IO_READ_ERROR, - BLOCKING_IO_WRITE_ERROR, - ) + _sslConn = SSLSocket # type: ignore[assignment, misc] + +from pymongo.ssl_support import ( + BLOCKING_IO_LOOKUP_ERROR, + BLOCKING_IO_READ_ERROR, + BLOCKING_IO_WRITE_ERROR, +) if TYPE_CHECKING: from pymongo.asynchronous.pool import AsyncConnection @@ -71,7 +67,11 @@ _UNPACK_COMPRESSION_HEADER = struct.Struct(" Union[socket. try: # We have to pass hostname / ip address to wrap_socket # to use SSLContext.check_hostname. - if HAS_SNI: + if _has_sni(True): ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host) # type: ignore[assignment, misc, unused-ignore] else: ssl_sock = ssl_context.wrap_socket(sock) # type: ignore[assignment, misc, unused-ignore] @@ -467,7 +468,7 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket. # Raise _CertificateError directly like we do after match_hostname # below. raise - except (OSError, SSLError) as exc: + except (OSError, *SSLErrors) as exc: sock.close() # We raise AutoReconnect for transient and permanent SSL handshake # failures alike. Permanent handshake failures, like protocol @@ -507,7 +508,7 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net try: # We have to pass hostname / ip address to wrap_socket # to use SSLContext.check_hostname. - if HAS_SNI: + if _has_sni(True): ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host) else: ssl_sock = ssl_context.wrap_socket(sock) @@ -516,7 +517,7 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net # Raise _CertificateError directly like we do after match_hostname # below. raise - except (OSError, SSLError) as exc: + except (OSError, *SSLErrors) as exc: sock.close() # We raise AutoReconnect for transient and permanent SSL handshake # failures alike. Permanent handshake failures, like protocol diff --git a/pymongo/ssl_support.py b/pymongo/ssl_support.py index 2e6a509e3e..aa6301fe73 100644 --- a/pymongo/ssl_support.py +++ b/pymongo/ssl_support.py @@ -15,16 +15,19 @@ """Support for SSL in PyMongo.""" from __future__ import annotations +import types import warnings -from typing import Optional +from typing import Any, Optional, Union from pymongo.errors import ConfigurationError HAVE_SSL = True +HAVE_PYSSL = True try: - import pymongo.pyopenssl_context as _ssl + import pymongo.pyopenssl_context as _pyssl except (ImportError, AttributeError) as exc: + HAVE_PYSSL = False if isinstance(exc, AttributeError): warnings.warn( "Failed to use the installed version of PyOpenSSL. " @@ -35,10 +38,10 @@ UserWarning, stacklevel=2, ) - try: - import pymongo.ssl_context as _ssl # type: ignore[no-redef] - except ImportError: - HAVE_SSL = False +try: + import pymongo.ssl_context as _ssl +except ImportError: + HAVE_SSL = False if HAVE_SSL: @@ -49,14 +52,32 @@ import ssl as _stdlibssl # noqa: F401 from ssl import CERT_NONE, CERT_REQUIRED - HAS_SNI = _ssl.HAS_SNI IPADDR_SAFE = True + + if HAVE_PYSSL: + PYSSLError: Any = _pyssl.SSLError + BLOCKING_IO_ERRORS: tuple = _pyssl.BLOCKING_IO_ERRORS + _ssl.BLOCKING_IO_ERRORS + BLOCKING_IO_READ_ERROR: tuple = ( + _pyssl.BLOCKING_IO_READ_ERROR, + _ssl.BLOCKING_IO_READ_ERROR, + ) + BLOCKING_IO_WRITE_ERROR: tuple = ( + _pyssl.BLOCKING_IO_WRITE_ERROR, + _ssl.BLOCKING_IO_WRITE_ERROR, + ) + else: + PYSSLError = _ssl.SSLError + BLOCKING_IO_ERRORS = _ssl.BLOCKING_IO_ERRORS + BLOCKING_IO_READ_ERROR = (_ssl.BLOCKING_IO_READ_ERROR,) + BLOCKING_IO_WRITE_ERROR = (_ssl.BLOCKING_IO_WRITE_ERROR,) SSLError = _ssl.SSLError - BLOCKING_IO_ERRORS = _ssl.BLOCKING_IO_ERRORS - BLOCKING_IO_READ_ERROR = _ssl.BLOCKING_IO_READ_ERROR - BLOCKING_IO_WRITE_ERROR = _ssl.BLOCKING_IO_WRITE_ERROR BLOCKING_IO_LOOKUP_ERROR = BLOCKING_IO_READ_ERROR + def _has_sni(is_sync: bool) -> bool: + if is_sync and HAVE_PYSSL: + return _pyssl.HAS_SNI + return _ssl.HAS_SNI + def get_ssl_context( certfile: Optional[str], passphrase: Optional[str], @@ -65,10 +86,15 @@ def get_ssl_context( allow_invalid_certificates: bool, allow_invalid_hostnames: bool, disable_ocsp_endpoint_check: bool, - ) -> _ssl.SSLContext: + is_sync: bool, + ) -> Union[_pyssl.SSLContext, _ssl.SSLContext]: # type: ignore[name-defined] """Create and return an SSLContext object.""" + if is_sync and HAVE_PYSSL: + ssl: types.ModuleType = _pyssl + else: + ssl = _ssl verify_mode = CERT_NONE if allow_invalid_certificates else CERT_REQUIRED - ctx = _ssl.SSLContext(_ssl.PROTOCOL_SSLv23) + ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23) if verify_mode != CERT_NONE: ctx.check_hostname = not allow_invalid_hostnames else: @@ -80,22 +106,20 @@ def get_ssl_context( # up to date versions of MongoDB 2.4 and above already disable # SSLv2 and SSLv3, python disables SSLv2 by default in >= 2.7.7 # and >= 3.3.4 and SSLv3 in >= 3.4.3. - ctx.options |= _ssl.OP_NO_SSLv2 - ctx.options |= _ssl.OP_NO_SSLv3 - ctx.options |= _ssl.OP_NO_COMPRESSION - ctx.options |= _ssl.OP_NO_RENEGOTIATION + ctx.options |= ssl.OP_NO_SSLv2 + ctx.options |= ssl.OP_NO_SSLv3 + ctx.options |= ssl.OP_NO_COMPRESSION + ctx.options |= ssl.OP_NO_RENEGOTIATION if certfile is not None: try: ctx.load_cert_chain(certfile, None, passphrase) - except _ssl.SSLError as exc: + except ssl.SSLError as exc: raise ConfigurationError(f"Private key doesn't match certificate: {exc}") from None if crlfile is not None: - if _ssl.IS_PYOPENSSL: + if ssl.IS_PYOPENSSL: raise ConfigurationError("tlsCRLFile cannot be used with PyOpenSSL") # Match the server's behavior. - ctx.verify_flags = getattr( # type:ignore[attr-defined] - _ssl, "VERIFY_CRL_CHECK_LEAF", 0 - ) + ctx.verify_flags = getattr(ssl, "VERIFY_CRL_CHECK_LEAF", 0) ctx.load_verify_locations(crlfile) if ca_certs is not None: ctx.load_verify_locations(ca_certs) @@ -109,9 +133,11 @@ def get_ssl_context( class SSLError(Exception): # type: ignore pass - HAS_SNI = False IPADDR_SAFE = False - BLOCKING_IO_ERRORS = () # type:ignore[assignment] + BLOCKING_IO_ERRORS = () + + def _has_sni(is_sync: bool) -> bool: # noqa: ARG001 + return False def get_ssl_context(*dummy): # type: ignore """No ssl module, raise ConfigurationError.""" diff --git a/pymongo/synchronous/encryption.py b/pymongo/synchronous/encryption.py index ed631e135d..418b2e469b 100644 --- a/pymongo/synchronous/encryption.py +++ b/pymongo/synchronous/encryption.py @@ -86,7 +86,7 @@ from pymongo.synchronous.database import Database from pymongo.synchronous.mongo_client import MongoClient from pymongo.typings import _DocumentType, _DocumentTypeArg -from pymongo.uri_parser_shared import parse_host +from pymongo.uri_parser_shared import _parse_kms_tls_options, parse_host from pymongo.write_concern import WriteConcern if TYPE_CHECKING: @@ -156,6 +156,7 @@ def __init__( self.mongocryptd_client = mongocryptd_client self.opts = opts self._spawned = False + self._kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) def kms_request(self, kms_context: MongoCryptKmsContext) -> None: """Complete a KMS request. @@ -167,7 +168,7 @@ def kms_request(self, kms_context: MongoCryptKmsContext) -> None: endpoint = kms_context.endpoint message = kms_context.message provider = kms_context.kms_provider - ctx = self.opts._kms_ssl_contexts.get(provider) + ctx = self._kms_ssl_contexts.get(provider) if ctx is None: # Enable strict certificate verification, OCSP, match hostname, and # SNI using the system default CA certificates. @@ -179,6 +180,7 @@ def kms_request(self, kms_context: MongoCryptKmsContext) -> None: False, # allow_invalid_certificates False, # allow_invalid_hostnames False, # disable_ocsp_endpoint_check + _IS_SYNC, ) # CSOT: set timeout for socket creation. connect_timeout = max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0.001) @@ -668,6 +670,7 @@ def __init__( kms_tls_options=kms_tls_options, key_expiration_ms=key_expiration_ms, ) + self._kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) self._io_callbacks: Optional[_EncryptionIO] = _EncryptionIO( None, key_vault_coll, None, opts ) diff --git a/pymongo/synchronous/pool.py b/pymongo/synchronous/pool.py index b3eec64f27..bf629d0326 100644 --- a/pymongo/synchronous/pool.py +++ b/pymongo/synchronous/pool.py @@ -83,7 +83,7 @@ from pymongo.server_api import _add_to_command from pymongo.server_type import SERVER_TYPE from pymongo.socket_checker import SocketChecker -from pymongo.ssl_support import SSLError +from pymongo.ssl_support import PYSSLError, SSLError from pymongo.synchronous.client_session import _validate_session_write_concern from pymongo.synchronous.helpers import _handle_reauth from pymongo.synchronous.network import command @@ -636,7 +636,7 @@ def _raise_connection_failure(self, error: BaseException) -> NoReturn: reason = ConnectionClosedReason.ERROR self.close_conn(reason) # SSLError from PyOpenSSL inherits directly from Exception. - if isinstance(error, (IOError, OSError, SSLError)): + if isinstance(error, (IOError, OSError, SSLError, PYSSLError)): details = _get_timeout_details(self.opts) _raise_connection_failure(self.address, error, timeout_details=details) else: @@ -1048,7 +1048,7 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR), error=ConnectionClosedReason.ERROR, ) - if isinstance(error, (IOError, OSError, SSLError)): + if isinstance(error, (IOError, OSError, SSLError, PYSSLError)): details = _get_timeout_details(self.opts) _raise_connection_failure(self.address, error, timeout_details=details) diff --git a/pymongo/uri_parser_shared.py b/pymongo/uri_parser_shared.py index e7ba4c9fb5..0cef176bf1 100644 --- a/pymongo/uri_parser_shared.py +++ b/pymongo/uri_parser_shared.py @@ -420,7 +420,10 @@ def _check_options(nodes: Sized, options: Mapping[str, Any]) -> None: raise ConfigurationError("Cannot specify replicaSet with loadBalanced=true") -def _parse_kms_tls_options(kms_tls_options: Optional[Mapping[str, Any]]) -> dict[str, SSLContext]: +def _parse_kms_tls_options( + kms_tls_options: Optional[Mapping[str, Any]], + is_sync: bool, +) -> dict[str, SSLContext]: """Parse KMS TLS connection options.""" if not kms_tls_options: return {} @@ -435,7 +438,7 @@ def _parse_kms_tls_options(kms_tls_options: Optional[Mapping[str, Any]]) -> dict opts = _handle_security_options(opts) opts = _normalize_options(opts) opts = cast(_CaseInsensitiveDictionary, validate_options(opts)) - ssl_context, allow_invalid_hostnames = _parse_ssl_options(opts) + ssl_context, allow_invalid_hostnames = _parse_ssl_options(opts, is_sync) if ssl_context is None: raise ConfigurationError("TLS is required for KMS providers") if allow_invalid_hostnames: diff --git a/test/asynchronous/test_encryption.py b/test/asynchronous/test_encryption.py index f9b03f6303..2ed3aeeab3 100644 --- a/test/asynchronous/test_encryption.py +++ b/test/asynchronous/test_encryption.py @@ -41,6 +41,7 @@ from pymongo.asynchronous.collection import AsyncCollection from pymongo.asynchronous.helpers import anext from pymongo.daemon import _spawn_daemon +from pymongo.uri_parser_shared import _parse_kms_tls_options try: from pymongo.pyopenssl_context import IS_PYOPENSSL @@ -141,7 +142,7 @@ def test_init(self): self.assertEqual(opts._mongocryptd_bypass_spawn, False) self.assertEqual(opts._mongocryptd_spawn_path, "mongocryptd") self.assertEqual(opts._mongocryptd_spawn_args, ["--idleShutdownTimeoutSecs=60"]) - self.assertEqual(opts._kms_ssl_contexts, {}) + self.assertEqual(opts._kms_tls_options, None) @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") def test_init_spawn_args(self): @@ -168,7 +169,9 @@ def test_init_spawn_args(self): def test_init_kms_tls_options(self): # Error cases: with self.assertRaisesRegex(TypeError, r'kms_tls_options\["kmip"\] must be a dict'): - AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": 1}) + opts = AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": 1}) + _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + tls_opts: Any for tls_opts in [ {"kmip": {"tls": True, "tlsInsecure": True}}, @@ -177,18 +180,24 @@ def test_init_kms_tls_options(self): ]: with self.assertRaisesRegex(ConfigurationError, "Insecure TLS options prohibited"): opts = AutoEncryptionOpts({}, "k.d", kms_tls_options=tls_opts) + _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) with self.assertRaises(FileNotFoundError): - AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": {"tlsCAFile": "does-not-exist"}}) + opts = AutoEncryptionOpts( + {}, "k.d", kms_tls_options={"kmip": {"tlsCAFile": "does-not-exist"}} + ) + _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) # Success cases: tls_opts: Any for tls_opts in [None, {}]: opts = AutoEncryptionOpts({}, "k.d", kms_tls_options=tls_opts) - self.assertEqual(opts._kms_ssl_contexts, {}) + kms_tls_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + self.assertEqual(kms_tls_contexts, {}) opts = AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": {"tls": True}, "aws": {}}) - ctx = opts._kms_ssl_contexts["kmip"] + _kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + ctx = _kms_ssl_contexts["kmip"] self.assertEqual(ctx.check_hostname, True) self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED) - ctx = opts._kms_ssl_contexts["aws"] + ctx = _kms_ssl_contexts["aws"] self.assertEqual(ctx.check_hostname, True) self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED) opts = AutoEncryptionOpts( @@ -196,7 +205,8 @@ def test_init_kms_tls_options(self): "k.d", kms_tls_options={"kmip": {"tlsCAFile": CA_PEM, "tlsCertificateKeyFile": CLIENT_PEM}}, ) - ctx = opts._kms_ssl_contexts["kmip"] + _kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + ctx = _kms_ssl_contexts["kmip"] self.assertEqual(ctx.check_hostname, True) self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED) @@ -2225,7 +2235,7 @@ async def test_05_tlsDisableOCSPEndpointCheck_is_permitted(self): encryption = self.create_client_encryption( providers, "keyvault.datakeys", self.client, OPTS, kms_tls_options=options ) - ctx = encryption._io_callbacks.opts._kms_ssl_contexts["aws"] + ctx = encryption._io_callbacks._kms_ssl_contexts["aws"] if not hasattr(ctx, "check_ocsp_endpoint"): raise self.skipTest("OCSP not enabled") self.assertFalse(ctx.check_ocsp_endpoint) diff --git a/test/asynchronous/test_ssl.py b/test/asynchronous/test_ssl.py index 4d7566a61d..023ee91680 100644 --- a/test/asynchronous/test_ssl.py +++ b/test/asynchronous/test_ssl.py @@ -43,7 +43,7 @@ from pymongo import AsyncMongoClient, ssl_support from pymongo.errors import ConfigurationError, ConnectionFailure, OperationFailure from pymongo.hello import HelloCompat -from pymongo.ssl_support import HAVE_SSL, _ssl, get_ssl_context +from pymongo.ssl_support import HAVE_PYSSL, HAVE_SSL, _ssl, get_ssl_context from pymongo.write_concern import WriteConcern _HAVE_PYOPENSSL = False @@ -134,7 +134,7 @@ def test_config_ssl(self): @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") def test_use_pyopenssl_when_available(self): - self.assertTrue(_ssl.IS_PYOPENSSL) + self.assertTrue(HAVE_PYSSL) @unittest.skipUnless(_HAVE_PYOPENSSL, "Cannot test without PyOpenSSL") def test_load_trusted_ca_certs(self): @@ -180,7 +180,7 @@ async def test_tlsCertificateKeyFilePassword(self): # # --sslPEMKeyFile=/path/to/pymongo/test/certificates/server.pem # --sslCAFile=/path/to/pymongo/test/certificates/ca.pem - if not hasattr(ssl, "SSLContext") and not _ssl.IS_PYOPENSSL: + if not hasattr(ssl, "SSLContext") and not HAVE_PYSSL: self.assertRaises( ConfigurationError, self.simple_client, @@ -312,13 +312,13 @@ async def test_cert_ssl_validation_hostname_matching(self): # # --sslPEMKeyFile=/path/to/pymongo/test/certificates/server.pem # --sslCAFile=/path/to/pymongo/test/certificates/ca.pem - ctx = get_ssl_context(None, None, None, None, True, True, False) + ctx = get_ssl_context(None, None, None, None, True, True, False, _IS_SYNC) self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context(None, None, None, None, True, False, False) + ctx = get_ssl_context(None, None, None, None, True, False, False, _IS_SYNC) self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context(None, None, None, None, False, True, False) + ctx = get_ssl_context(None, None, None, None, False, True, False, _IS_SYNC) self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context(None, None, None, None, False, False, False) + ctx = get_ssl_context(None, None, None, None, False, False, False, _IS_SYNC) self.assertTrue(ctx.check_hostname) response = await self.client.admin.command(HelloCompat.LEGACY_CMD) @@ -379,10 +379,11 @@ async def test_cert_ssl_validation_hostname_matching(self): ) @async_client_context.require_tlsCertificateKeyFile + @async_client_context.require_sync @async_client_context.require_no_api_version @ignore_deprecations async def test_tlsCRLFile_support(self): - if not hasattr(ssl, "VERIFY_CRL_CHECK_LEAF") or _ssl.IS_PYOPENSSL: + if not hasattr(ssl, "VERIFY_CRL_CHECK_LEAF") or HAVE_PYSSL: self.assertRaises( ConfigurationError, self.simple_client, @@ -473,7 +474,7 @@ async def test_validation_with_system_ca_certs(self): ) def test_system_certs_config_error(self): - ctx = get_ssl_context(None, None, None, None, True, True, False) + ctx = get_ssl_context(None, None, None, None, True, True, False, _IS_SYNC) if (sys.platform != "win32" and hasattr(ctx, "set_default_verify_paths")) or hasattr( ctx, "load_default_certs" ): @@ -504,11 +505,11 @@ def test_certifi_support(self): # Force the test on Windows, regardless of environment. ssl_support.HAVE_WINCERTSTORE = False try: - ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False) + ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, CA_PEM) - ctx = get_ssl_context(None, None, None, None, False, False, False) + ctx = get_ssl_context(None, None, None, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, ssl_support.certifi.where()) finally: @@ -525,11 +526,11 @@ def test_wincertstore(self): if not ssl_support.HAVE_WINCERTSTORE: raise SkipTest("Need wincertstore to test wincertstore.") - ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False) + ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, CA_PEM) - ctx = get_ssl_context(None, None, None, None, False, False, False) + ctx = get_ssl_context(None, None, None, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, ssl_support._WINCERTS.name) @@ -663,6 +664,16 @@ def remove(path): ) as client: self.assertTrue(await client.admin.command("ping")) + @async_client_context.require_async + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + @unittest.skipUnless(HAVE_SSL, "The ssl module is not available.") + async def test_pyopenssl_ignored_in_async(self): + client = AsyncMongoClient( + "mongodb://localhost:27017?tls=true&tlsAllowInvalidCertificates=true" + ) + await client.admin.command("ping") # command doesn't matter, just needs it to connect + await client.close() + if __name__ == "__main__": unittest.main() diff --git a/test/atlas/test_connection.py b/test/atlas/test_connection.py index 3d34ff326e..a3e8b0b1d5 100644 --- a/test/atlas/test_connection.py +++ b/test/atlas/test_connection.py @@ -26,7 +26,7 @@ sys.path[0:0] = [""] import pymongo -from pymongo.ssl_support import HAS_SNI +from pymongo.ssl_support import _has_sni pytestmark = pytest.mark.atlas_connect @@ -57,7 +57,7 @@ def connect(self, uri): # No auth error client.test.test.count_documents({}) - @unittest.skipUnless(HAS_SNI, "Free tier requires SNI support") + @unittest.skipUnless(_has_sni(True), "Free tier requires SNI support") def test_free_tier(self): self.connect(URIS["ATLAS_FREE"]) @@ -80,7 +80,7 @@ def connect_srv(self, uri): self.connect(uri) self.assertIn("mongodb+srv://", uri) - @unittest.skipUnless(HAS_SNI, "Free tier requires SNI support") + @unittest.skipUnless(_has_sni(True), "Free tier requires SNI support") def test_srv_free_tier(self): self.connect_srv(URIS["ATLAS_SRV_FREE"]) diff --git a/test/test_encryption.py b/test/test_encryption.py index 5bbf8c8ad8..6611b20e37 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -41,6 +41,7 @@ from pymongo.daemon import _spawn_daemon from pymongo.synchronous.collection import Collection from pymongo.synchronous.helpers import next +from pymongo.uri_parser_shared import _parse_kms_tls_options try: from pymongo.pyopenssl_context import IS_PYOPENSSL @@ -141,7 +142,7 @@ def test_init(self): self.assertEqual(opts._mongocryptd_bypass_spawn, False) self.assertEqual(opts._mongocryptd_spawn_path, "mongocryptd") self.assertEqual(opts._mongocryptd_spawn_args, ["--idleShutdownTimeoutSecs=60"]) - self.assertEqual(opts._kms_ssl_contexts, {}) + self.assertEqual(opts._kms_tls_options, None) @unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is not installed") def test_init_spawn_args(self): @@ -168,7 +169,9 @@ def test_init_spawn_args(self): def test_init_kms_tls_options(self): # Error cases: with self.assertRaisesRegex(TypeError, r'kms_tls_options\["kmip"\] must be a dict'): - AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": 1}) + opts = AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": 1}) + _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + tls_opts: Any for tls_opts in [ {"kmip": {"tls": True, "tlsInsecure": True}}, @@ -177,18 +180,24 @@ def test_init_kms_tls_options(self): ]: with self.assertRaisesRegex(ConfigurationError, "Insecure TLS options prohibited"): opts = AutoEncryptionOpts({}, "k.d", kms_tls_options=tls_opts) + _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) with self.assertRaises(FileNotFoundError): - AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": {"tlsCAFile": "does-not-exist"}}) + opts = AutoEncryptionOpts( + {}, "k.d", kms_tls_options={"kmip": {"tlsCAFile": "does-not-exist"}} + ) + _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) # Success cases: tls_opts: Any for tls_opts in [None, {}]: opts = AutoEncryptionOpts({}, "k.d", kms_tls_options=tls_opts) - self.assertEqual(opts._kms_ssl_contexts, {}) + kms_tls_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + self.assertEqual(kms_tls_contexts, {}) opts = AutoEncryptionOpts({}, "k.d", kms_tls_options={"kmip": {"tls": True}, "aws": {}}) - ctx = opts._kms_ssl_contexts["kmip"] + _kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + ctx = _kms_ssl_contexts["kmip"] self.assertEqual(ctx.check_hostname, True) self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED) - ctx = opts._kms_ssl_contexts["aws"] + ctx = _kms_ssl_contexts["aws"] self.assertEqual(ctx.check_hostname, True) self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED) opts = AutoEncryptionOpts( @@ -196,7 +205,8 @@ def test_init_kms_tls_options(self): "k.d", kms_tls_options={"kmip": {"tlsCAFile": CA_PEM, "tlsCertificateKeyFile": CLIENT_PEM}}, ) - ctx = opts._kms_ssl_contexts["kmip"] + _kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC) + ctx = _kms_ssl_contexts["kmip"] self.assertEqual(ctx.check_hostname, True) self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED) @@ -2217,7 +2227,7 @@ def test_05_tlsDisableOCSPEndpointCheck_is_permitted(self): encryption = self.create_client_encryption( providers, "keyvault.datakeys", self.client, OPTS, kms_tls_options=options ) - ctx = encryption._io_callbacks.opts._kms_ssl_contexts["aws"] + ctx = encryption._io_callbacks._kms_ssl_contexts["aws"] if not hasattr(ctx, "check_ocsp_endpoint"): raise self.skipTest("OCSP not enabled") self.assertFalse(ctx.check_ocsp_endpoint) diff --git a/test/test_ssl.py b/test/test_ssl.py index 7decc8203d..93a4b4e6ec 100644 --- a/test/test_ssl.py +++ b/test/test_ssl.py @@ -43,7 +43,7 @@ from pymongo import MongoClient, ssl_support from pymongo.errors import ConfigurationError, ConnectionFailure, OperationFailure from pymongo.hello import HelloCompat -from pymongo.ssl_support import HAVE_SSL, _ssl, get_ssl_context +from pymongo.ssl_support import HAVE_PYSSL, HAVE_SSL, _ssl, get_ssl_context from pymongo.write_concern import WriteConcern _HAVE_PYOPENSSL = False @@ -134,7 +134,7 @@ def test_config_ssl(self): @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") def test_use_pyopenssl_when_available(self): - self.assertTrue(_ssl.IS_PYOPENSSL) + self.assertTrue(HAVE_PYSSL) @unittest.skipUnless(_HAVE_PYOPENSSL, "Cannot test without PyOpenSSL") def test_load_trusted_ca_certs(self): @@ -180,7 +180,7 @@ def test_tlsCertificateKeyFilePassword(self): # # --sslPEMKeyFile=/path/to/pymongo/test/certificates/server.pem # --sslCAFile=/path/to/pymongo/test/certificates/ca.pem - if not hasattr(ssl, "SSLContext") and not _ssl.IS_PYOPENSSL: + if not hasattr(ssl, "SSLContext") and not HAVE_PYSSL: self.assertRaises( ConfigurationError, self.simple_client, @@ -312,13 +312,13 @@ def test_cert_ssl_validation_hostname_matching(self): # # --sslPEMKeyFile=/path/to/pymongo/test/certificates/server.pem # --sslCAFile=/path/to/pymongo/test/certificates/ca.pem - ctx = get_ssl_context(None, None, None, None, True, True, False) + ctx = get_ssl_context(None, None, None, None, True, True, False, _IS_SYNC) self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context(None, None, None, None, True, False, False) + ctx = get_ssl_context(None, None, None, None, True, False, False, _IS_SYNC) self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context(None, None, None, None, False, True, False) + ctx = get_ssl_context(None, None, None, None, False, True, False, _IS_SYNC) self.assertFalse(ctx.check_hostname) - ctx = get_ssl_context(None, None, None, None, False, False, False) + ctx = get_ssl_context(None, None, None, None, False, False, False, _IS_SYNC) self.assertTrue(ctx.check_hostname) response = self.client.admin.command(HelloCompat.LEGACY_CMD) @@ -379,10 +379,11 @@ def test_cert_ssl_validation_hostname_matching(self): ) @client_context.require_tlsCertificateKeyFile + @client_context.require_sync @client_context.require_no_api_version @ignore_deprecations def test_tlsCRLFile_support(self): - if not hasattr(ssl, "VERIFY_CRL_CHECK_LEAF") or _ssl.IS_PYOPENSSL: + if not hasattr(ssl, "VERIFY_CRL_CHECK_LEAF") or HAVE_PYSSL: self.assertRaises( ConfigurationError, self.simple_client, @@ -473,7 +474,7 @@ def test_validation_with_system_ca_certs(self): ) def test_system_certs_config_error(self): - ctx = get_ssl_context(None, None, None, None, True, True, False) + ctx = get_ssl_context(None, None, None, None, True, True, False, _IS_SYNC) if (sys.platform != "win32" and hasattr(ctx, "set_default_verify_paths")) or hasattr( ctx, "load_default_certs" ): @@ -504,11 +505,11 @@ def test_certifi_support(self): # Force the test on Windows, regardless of environment. ssl_support.HAVE_WINCERTSTORE = False try: - ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False) + ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, CA_PEM) - ctx = get_ssl_context(None, None, None, None, False, False, False) + ctx = get_ssl_context(None, None, None, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, ssl_support.certifi.where()) finally: @@ -525,11 +526,11 @@ def test_wincertstore(self): if not ssl_support.HAVE_WINCERTSTORE: raise SkipTest("Need wincertstore to test wincertstore.") - ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False) + ctx = get_ssl_context(None, None, CA_PEM, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, CA_PEM) - ctx = get_ssl_context(None, None, None, None, False, False, False) + ctx = get_ssl_context(None, None, None, None, False, False, False, _IS_SYNC) ssl_sock = ctx.wrap_socket(socket.socket()) self.assertEqual(ssl_sock.ca_certs, ssl_support._WINCERTS.name) @@ -663,6 +664,14 @@ def remove(path): ) as client: self.assertTrue(client.admin.command("ping")) + @client_context.require_async + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + @unittest.skipUnless(HAVE_SSL, "The ssl module is not available.") + def test_pyopenssl_ignored_in_async(self): + client = MongoClient("mongodb://localhost:27017?tls=true&tlsAllowInvalidCertificates=true") + client.admin.command("ping") # command doesn't matter, just needs it to connect + client.close() + if __name__ == "__main__": unittest.main() diff --git a/tools/ocsptest.py b/tools/ocsptest.py index 521d048f79..8596db226d 100644 --- a/tools/ocsptest.py +++ b/tools/ocsptest.py @@ -35,6 +35,7 @@ def check_ocsp(host: str, port: int, capath: str) -> None: False, # allow_invalid_certificates False, # allow_invalid_hostnames False, + True, # is sync ) # disable_ocsp_endpoint_check # Ensure we're using pyOpenSSL.