Skip to content

Commit c3e3373

Browse files
PYTHON-5309 Ensure AsyncMongoClient doesn't use PyOpenSSL (#2286)
Co-authored-by: Noah Stapp <[email protected]>
1 parent dae4f7f commit c3e3373

19 files changed

+232
-122
lines changed

.evergreen/generated_configs/variants.yml

+10-4
Original file line numberDiff line numberDiff line change
@@ -620,17 +620,19 @@ buildvariants:
620620
- macos-14
621621
batchtime: 10080
622622
expansions:
623+
TEST_NAME: default
623624
SUB_TEST_NAME: pyopenssl
624625
PYTHON_BINARY: /Library/Frameworks/Python.Framework/Versions/3.9/bin/python3
625626
- name: pyopenssl-rhel8-python3.10
626627
tasks:
627-
- name: .replica_set .auth .ssl .sync
628-
- name: .7.0 .auth .ssl .sync
628+
- name: .replica_set .auth .ssl .sync_async
629+
- name: .7.0 .auth .ssl .sync_async
629630
display_name: PyOpenSSL RHEL8 Python3.10
630631
run_on:
631632
- rhel87-small
632633
batchtime: 10080
633634
expansions:
635+
TEST_NAME: default
634636
SUB_TEST_NAME: pyopenssl
635637
PYTHON_BINARY: /opt/python/3.10/bin/python3
636638
- name: pyopenssl-rhel8-python3.11
@@ -642,6 +644,7 @@ buildvariants:
642644
- rhel87-small
643645
batchtime: 10080
644646
expansions:
647+
TEST_NAME: default
645648
SUB_TEST_NAME: pyopenssl
646649
PYTHON_BINARY: /opt/python/3.11/bin/python3
647650
- name: pyopenssl-rhel8-python3.12
@@ -653,17 +656,19 @@ buildvariants:
653656
- rhel87-small
654657
batchtime: 10080
655658
expansions:
659+
TEST_NAME: default
656660
SUB_TEST_NAME: pyopenssl
657661
PYTHON_BINARY: /opt/python/3.12/bin/python3
658662
- name: pyopenssl-win64-python3.13
659663
tasks:
660-
- name: .replica_set .auth .ssl .sync
661-
- name: .7.0 .auth .ssl .sync
664+
- name: .replica_set .auth .ssl .sync_async
665+
- name: .7.0 .auth .ssl .sync_async
662666
display_name: PyOpenSSL Win64 Python3.13
663667
run_on:
664668
- windows-64-vsMulti-small
665669
batchtime: 10080
666670
expansions:
671+
TEST_NAME: default
667672
SUB_TEST_NAME: pyopenssl
668673
PYTHON_BINARY: C:/python/Python313/python.exe
669674
- name: pyopenssl-rhel8-pypy3.10
@@ -675,6 +680,7 @@ buildvariants:
675680
- rhel87-small
676681
batchtime: 10080
677682
expansions:
683+
TEST_NAME: default
678684
SUB_TEST_NAME: pyopenssl
679685
PYTHON_BINARY: /opt/python/pypy3.10/bin/python3
680686

.evergreen/scripts/generate_config.py

+20-9
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def create_enterprise_auth_variants():
250250
def create_pyopenssl_variants():
251251
base_name = "PyOpenSSL"
252252
batchtime = BATCHTIME_WEEK
253-
expansions = dict(SUB_TEST_NAME="pyopenssl")
253+
expansions = dict(TEST_NAME="default", SUB_TEST_NAME="pyopenssl")
254254
variants = []
255255

256256
for python in ALL_PYTHONS:
@@ -265,14 +265,25 @@ def create_pyopenssl_variants():
265265
host = DEFAULT_HOST
266266

267267
display_name = get_variant_name(base_name, host, python=python)
268-
variant = create_variant(
269-
[f".replica_set .{auth} .{ssl} .sync", f".7.0 .{auth} .{ssl} .sync"],
270-
display_name,
271-
python=python,
272-
host=host,
273-
expansions=expansions,
274-
batchtime=batchtime,
275-
)
268+
# only need to run some on async
269+
if python in (CPYTHONS[1], CPYTHONS[-1]):
270+
variant = create_variant(
271+
[f".replica_set .{auth} .{ssl} .sync_async", f".7.0 .{auth} .{ssl} .sync_async"],
272+
display_name,
273+
python=python,
274+
host=host,
275+
expansions=expansions,
276+
batchtime=batchtime,
277+
)
278+
else:
279+
variant = create_variant(
280+
[f".replica_set .{auth} .{ssl} .sync", f".7.0 .{auth} .{ssl} .sync"],
281+
display_name,
282+
python=python,
283+
host=host,
284+
expansions=expansions,
285+
batchtime=batchtime,
286+
)
276287
variants.append(variant)
277288

278289
return variants

doc/changelog.rst

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Version 4.12.1 is a bug fix release.
1818
Eventlet is actively being sunset by its maintainers and has compatibility issues with PyMongo's dnspython dependency.
1919
- Fixed a bug where MongoDB cluster topology changes could cause asynchronous operations to take much longer to complete
2020
due to holding the Topology lock while closing stale connections.
21+
- Fixed a bug that would cause AsyncMongoClient to attempt to use PyOpenSSL when available, resulting in errors such as
22+
"pymongo.errors.ServerSelectionTimeoutError: 'SSLContext' object has no attribute 'wrap_bio'".
2123

2224
Issues Resolved
2325
...............

pymongo/asynchronous/encryption.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
from pymongo.results import BulkWriteResult, DeleteResult
8888
from pymongo.ssl_support import BLOCKING_IO_ERRORS, get_ssl_context
8989
from pymongo.typings import _DocumentType, _DocumentTypeArg
90-
from pymongo.uri_parser_shared import parse_host
90+
from pymongo.uri_parser_shared import _parse_kms_tls_options, parse_host
9191
from pymongo.write_concern import WriteConcern
9292

9393
if TYPE_CHECKING:
@@ -157,6 +157,7 @@ def __init__(
157157
self.mongocryptd_client = mongocryptd_client
158158
self.opts = opts
159159
self._spawned = False
160+
self._kms_ssl_contexts = opts._kms_ssl_contexts(_IS_SYNC)
160161

161162
async def kms_request(self, kms_context: MongoCryptKmsContext) -> None:
162163
"""Complete a KMS request.
@@ -168,7 +169,7 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None:
168169
endpoint = kms_context.endpoint
169170
message = kms_context.message
170171
provider = kms_context.kms_provider
171-
ctx = self.opts._kms_ssl_contexts.get(provider)
172+
ctx = self._kms_ssl_contexts.get(provider)
172173
if ctx is None:
173174
# Enable strict certificate verification, OCSP, match hostname, and
174175
# SNI using the system default CA certificates.
@@ -180,6 +181,7 @@ async def kms_request(self, kms_context: MongoCryptKmsContext) -> None:
180181
False, # allow_invalid_certificates
181182
False, # allow_invalid_hostnames
182183
False, # disable_ocsp_endpoint_check
184+
_IS_SYNC,
183185
)
184186
# CSOT: set timeout for socket creation.
185187
connect_timeout = max(_csot.clamp_remaining(_KMS_CONNECT_TIMEOUT), 0.001)
@@ -396,6 +398,8 @@ def __init__(self, client: AsyncMongoClient[_DocumentTypeArg], opts: AutoEncrypt
396398
encrypted_fields_map = _dict_to_bson(opts._encrypted_fields_map, False, _DATA_KEY_OPTS)
397399
self._bypass_auto_encryption = opts._bypass_auto_encryption
398400
self._internal_client = None
401+
# parsing kms_ssl_contexts here so that parsing errors will be raised before internal clients are created
402+
opts._kms_ssl_contexts(_IS_SYNC)
399403

400404
def _get_internal_client(
401405
encrypter: _Encrypter, mongo_client: AsyncMongoClient[_DocumentTypeArg]
@@ -675,6 +679,7 @@ def __init__(
675679
kms_tls_options=kms_tls_options,
676680
key_expiration_ms=key_expiration_ms,
677681
)
682+
self._kms_ssl_contexts = _parse_kms_tls_options(opts._kms_tls_options, _IS_SYNC)
678683
self._io_callbacks: Optional[_EncryptionIO] = _EncryptionIO(
679684
None, key_vault_coll, None, opts
680685
)

pymongo/asynchronous/pool.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
from pymongo.network_layer import AsyncNetworkingInterface, async_receive_message, async_sendall
7777
from pymongo.pool_options import PoolOptions
7878
from pymongo.pool_shared import (
79+
SSLErrors,
7980
_CancellationContext,
8081
_configured_protocol_interface,
8182
_get_timeout_details,
@@ -86,7 +87,6 @@
8687
from pymongo.server_api import _add_to_command
8788
from pymongo.server_type import SERVER_TYPE
8889
from pymongo.socket_checker import SocketChecker
89-
from pymongo.ssl_support import SSLError
9090

9191
if TYPE_CHECKING:
9292
from bson import CodecOptions
@@ -638,7 +638,7 @@ async def _raise_connection_failure(self, error: BaseException) -> NoReturn:
638638
reason = ConnectionClosedReason.ERROR
639639
await self.close_conn(reason)
640640
# SSLError from PyOpenSSL inherits directly from Exception.
641-
if isinstance(error, (IOError, OSError, SSLError)):
641+
if isinstance(error, (IOError, OSError, *SSLErrors)):
642642
details = _get_timeout_details(self.opts)
643643
_raise_connection_failure(self.address, error, timeout_details=details)
644644
else:
@@ -1052,7 +1052,7 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A
10521052
reason=_verbose_connection_error_reason(ConnectionClosedReason.ERROR),
10531053
error=ConnectionClosedReason.ERROR,
10541054
)
1055-
if isinstance(error, (IOError, OSError, SSLError)):
1055+
if isinstance(error, (IOError, OSError, *SSLErrors)):
10561056
details = _get_timeout_details(self.opts)
10571057
_raise_connection_failure(self.address, error, timeout_details=details)
10581058

pymongo/client_options.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ def _parse_read_concern(options: Mapping[str, Any]) -> ReadConcern:
8484
return ReadConcern(concern)
8585

8686

87-
def _parse_ssl_options(options: Mapping[str, Any]) -> tuple[Optional[SSLContext], bool]:
87+
def _parse_ssl_options(
88+
options: Mapping[str, Any], is_sync: bool
89+
) -> tuple[Optional[SSLContext], bool]:
8890
"""Parse ssl options."""
8991
use_tls = options.get("tls")
9092
if use_tls is not None:
@@ -138,6 +140,7 @@ def _parse_ssl_options(options: Mapping[str, Any]) -> tuple[Optional[SSLContext]
138140
allow_invalid_certificates,
139141
allow_invalid_hostnames,
140142
disable_ocsp_endpoint_check,
143+
is_sync,
141144
)
142145
return ctx, allow_invalid_hostnames
143146
return None, allow_invalid_hostnames
@@ -167,7 +170,7 @@ def _parse_pool_options(
167170
compression_settings = CompressionSettings(
168171
options.get("compressors", []), options.get("zlibcompressionlevel", -1)
169172
)
170-
ssl_context, tls_allow_invalid_hostnames = _parse_ssl_options(options)
173+
ssl_context, tls_allow_invalid_hostnames = _parse_ssl_options(options, is_sync)
171174
load_balanced = options.get("loadbalanced")
172175
max_connecting = options.get("maxconnecting", common.MAX_CONNECTING)
173176
return PoolOptions(

pymongo/encryption_options.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
from typing import TYPE_CHECKING, Any, Mapping, Optional
2222

23+
from pymongo.uri_parser_shared import _parse_kms_tls_options
24+
2325
try:
2426
import pymongocrypt # type:ignore[import-untyped] # noqa: F401
2527

@@ -32,9 +34,9 @@
3234
from bson import int64
3335
from pymongo.common import validate_is_mapping
3436
from pymongo.errors import ConfigurationError
35-
from pymongo.uri_parser_shared import _parse_kms_tls_options
3637

3738
if TYPE_CHECKING:
39+
from pymongo.pyopenssl_context import SSLContext
3840
from pymongo.typings import _AgnosticMongoClient, _DocumentTypeArg
3941

4042

@@ -236,10 +238,22 @@ def __init__(
236238
if not any("idleShutdownTimeoutSecs" in s for s in self._mongocryptd_spawn_args):
237239
self._mongocryptd_spawn_args.append("--idleShutdownTimeoutSecs=60")
238240
# Maps KMS provider name to a SSLContext.
239-
self._kms_ssl_contexts = _parse_kms_tls_options(kms_tls_options)
241+
self._kms_tls_options = kms_tls_options
242+
self._sync_kms_ssl_contexts: Optional[dict[str, SSLContext]] = None
243+
self._async_kms_ssl_contexts: Optional[dict[str, SSLContext]] = None
240244
self._bypass_query_analysis = bypass_query_analysis
241245
self._key_expiration_ms = key_expiration_ms
242246

247+
def _kms_ssl_contexts(self, is_sync: bool) -> dict[str, SSLContext]:
248+
if is_sync:
249+
if self._sync_kms_ssl_contexts is None:
250+
self._sync_kms_ssl_contexts = _parse_kms_tls_options(self._kms_tls_options, True)
251+
return self._sync_kms_ssl_contexts
252+
else:
253+
if self._async_kms_ssl_contexts is None:
254+
self._async_kms_ssl_contexts = _parse_kms_tls_options(self._kms_tls_options, False)
255+
return self._async_kms_ssl_contexts
256+
243257

244258
class RangeOpts:
245259
"""Options to configure encrypted queries using the range algorithm."""

pymongo/network_layer.py

+9-13
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,18 @@
4646
_HAVE_SSL = False
4747

4848
try:
49-
from pymongo.pyopenssl_context import (
50-
BLOCKING_IO_LOOKUP_ERROR,
51-
BLOCKING_IO_READ_ERROR,
52-
BLOCKING_IO_WRITE_ERROR,
53-
_sslConn,
54-
)
49+
from pymongo.pyopenssl_context import _sslConn
5550

5651
_HAVE_PYOPENSSL = True
5752
except ImportError:
5853
_HAVE_PYOPENSSL = False
59-
_sslConn = SSLSocket # type: ignore
60-
from pymongo.ssl_support import ( # type: ignore[assignment]
61-
BLOCKING_IO_LOOKUP_ERROR,
62-
BLOCKING_IO_READ_ERROR,
63-
BLOCKING_IO_WRITE_ERROR,
64-
)
54+
_sslConn = SSLSocket # type: ignore[assignment, misc]
55+
56+
from pymongo.ssl_support import (
57+
BLOCKING_IO_LOOKUP_ERROR,
58+
BLOCKING_IO_READ_ERROR,
59+
BLOCKING_IO_WRITE_ERROR,
60+
)
6561

6662
if TYPE_CHECKING:
6763
from pymongo.asynchronous.pool import AsyncConnection
@@ -71,7 +67,7 @@
7167
_UNPACK_COMPRESSION_HEADER = struct.Struct("<iiB").unpack
7268
_POLL_TIMEOUT = 0.5
7369
# Errors raised by sockets (and TLS sockets) when in non-blocking mode.
74-
BLOCKING_IO_ERRORS = (BlockingIOError, BLOCKING_IO_LOOKUP_ERROR, *ssl_support.BLOCKING_IO_ERRORS)
70+
BLOCKING_IO_ERRORS = (BlockingIOError, *BLOCKING_IO_LOOKUP_ERROR, *ssl_support.BLOCKING_IO_ERRORS)
7571

7672

7773
# These socket-based I/O methods are for KMS requests and any other network operations that do not use

pymongo/pool_shared.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@
3838
)
3939
from pymongo.network_layer import AsyncNetworkingInterface, NetworkingInterface, PyMongoProtocol
4040
from pymongo.pool_options import PoolOptions
41-
from pymongo.ssl_support import HAS_SNI, SSLError
41+
from pymongo.ssl_support import PYSSLError, SSLError, _has_sni
4242

43+
SSLErrors = (PYSSLError, SSLError)
4344
if TYPE_CHECKING:
4445
from pymongo.pyopenssl_context import _sslConn
4546
from pymongo.typings import _Address
@@ -138,7 +139,7 @@ def _raise_connection_failure(
138139
msg += format_timeout_details(timeout_details)
139140
if isinstance(error, socket.timeout):
140141
raise NetworkTimeout(msg) from error
141-
elif isinstance(error, SSLError) and "timed out" in str(error):
142+
elif isinstance(error, SSLErrors) and "timed out" in str(error):
142143
# Eventlet does not distinguish TLS network timeouts from other
143144
# SSLErrors (https://github.com/eventlet/eventlet/issues/692).
144145
# Luckily, we can work around this limitation because the phrase
@@ -279,7 +280,7 @@ async def _async_configured_socket(
279280
try:
280281
# We have to pass hostname / ip address to wrap_socket
281282
# to use SSLContext.check_hostname.
282-
if HAS_SNI:
283+
if _has_sni(False):
283284
loop = asyncio.get_running_loop()
284285
ssl_sock = await loop.run_in_executor(
285286
None,
@@ -293,7 +294,7 @@ async def _async_configured_socket(
293294
# Raise _CertificateError directly like we do after match_hostname
294295
# below.
295296
raise
296-
except (OSError, SSLError) as exc:
297+
except (OSError, *SSLErrors) as exc:
297298
sock.close()
298299
# We raise AutoReconnect for transient and permanent SSL handshake
299300
# failures alike. Permanent handshake failures, like protocol
@@ -349,7 +350,7 @@ async def _configured_protocol_interface(
349350
# Raise _CertificateError directly like we do after match_hostname
350351
# below.
351352
raise
352-
except (OSError, SSLError) as exc:
353+
except (OSError, *SSLErrors) as exc:
353354
# We raise AutoReconnect for transient and permanent SSL handshake
354355
# failures alike. Permanent handshake failures, like protocol
355356
# mismatch, will be turned into ServerSelectionTimeoutErrors later.
@@ -458,7 +459,7 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.
458459
try:
459460
# We have to pass hostname / ip address to wrap_socket
460461
# to use SSLContext.check_hostname.
461-
if HAS_SNI:
462+
if _has_sni(True):
462463
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host) # type: ignore[assignment, misc, unused-ignore]
463464
else:
464465
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.
467468
# Raise _CertificateError directly like we do after match_hostname
468469
# below.
469470
raise
470-
except (OSError, SSLError) as exc:
471+
except (OSError, *SSLErrors) as exc:
471472
sock.close()
472473
# We raise AutoReconnect for transient and permanent SSL handshake
473474
# failures alike. Permanent handshake failures, like protocol
@@ -507,7 +508,7 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
507508
try:
508509
# We have to pass hostname / ip address to wrap_socket
509510
# to use SSLContext.check_hostname.
510-
if HAS_SNI:
511+
if _has_sni(True):
511512
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host)
512513
else:
513514
ssl_sock = ssl_context.wrap_socket(sock)
@@ -516,7 +517,7 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
516517
# Raise _CertificateError directly like we do after match_hostname
517518
# below.
518519
raise
519-
except (OSError, SSLError) as exc:
520+
except (OSError, *SSLErrors) as exc:
520521
sock.close()
521522
# We raise AutoReconnect for transient and permanent SSL handshake
522523
# failures alike. Permanent handshake failures, like protocol

0 commit comments

Comments
 (0)