diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 00bcc9bd91..9618bb919b 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -467,7 +467,7 @@ buildvariants: - name: .test-standard !.pypy .async .replica_set-noauth-ssl display_name: PyOpenSSL macOS run_on: - - rhel87-small + - macos-14 batchtime: 1440 expansions: SUB_TEST_NAME: pyopenssl diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index bfc79849c5..3318625a25 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -251,6 +251,7 @@ def create_pyopenssl_variants(): create_variant( tasks, display_name, + host=host, expansions=expansions, batchtime=batchtime, ) diff --git a/doc/changelog.rst b/doc/changelog.rst index cda288c575..6b6c379eba 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -27,6 +27,9 @@ PyMongo 4.18 brings a number of changes including: buffer. - Fixed :func:`bson.json_util.loads` to reject ``$timestamp`` values containing fields other than ``t`` and ``i``. +- Fixed a bug on Windows, and on macOS when using PyOpenSSL, where + ``SSL_CERT_FILE``/``SSL_CERT_DIR`` were merged with, rather than replacing, + the OS/certifi certificate store. Changes in Version 4.17.0 (2026/04/20) -------------------------------------- diff --git a/pymongo/ssl_support.py b/pymongo/ssl_support.py index 65196ef945..d505f8df27 100644 --- a/pymongo/ssl_support.py +++ b/pymongo/ssl_support.py @@ -16,6 +16,8 @@ from __future__ import annotations +import os +import sys import types import warnings from typing import Any, Optional, Union @@ -133,7 +135,17 @@ def get_ssl_context( if ca_certs is not None: ctx.load_verify_locations(ca_certs) elif verify_mode != CERT_NONE: - ctx.load_default_certs() + cert_file = os.environ.get("SSL_CERT_FILE") or None + cert_dir = os.environ.get("SSL_CERT_DIR") or None + # load_default_certs() wrongly merges in the OS/certifi store on + # Windows and on macOS with PyOpenSSL + merges_os_store = sys.platform == "win32" or ( + ssl.IS_PYOPENSSL and sys.platform == "darwin" + ) + if (cert_file or cert_dir) and merges_os_store: + ctx.load_verify_locations(cafile=cert_file, capath=cert_dir) + else: + ctx.load_default_certs() ctx.verify_mode = verify_mode return ctx diff --git a/test/test_ssl_support.py b/test/test_ssl_support.py new file mode 100644 index 0000000000..e4bfef2e05 --- /dev/null +++ b/test/test_ssl_support.py @@ -0,0 +1,97 @@ +# Copyright 2026-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for ssl_support.py. + +These are pure unit tests with no async/sync variants, so this file is not +mirrored by ``just synchro``. +""" + +from __future__ import annotations + +import os +import sys +import unittest.mock as mock + +sys.path[0:0] = [""] + +from pymongo.ssl_support import HAVE_SSL, get_ssl_context +from test import PyMongoTestCase, unittest +from test.helpers_shared import CA_PEM + +_HAVE_PYOPENSSL = False +try: + from pymongo import pyopenssl_context + + _HAVE_PYOPENSSL = True +except ImportError: + pass + + +@unittest.skipUnless(HAVE_SSL, "The ssl module is not available.") +class TestSSLCertFileEnvVar(PyMongoTestCase): + def test_uses_default_certs_on_linux(self): + # PYTHON-5930: on Linux, load_default_certs() already honors SSL_CERT_FILE + # correctly (unlike Windows/macOS+PyOpenSSL), so it must still be called + # instead of bypassed. + env = dict(os.environ) + env.pop("SSL_CERT_DIR", None) + env["SSL_CERT_FILE"] = CA_PEM + with ( + mock.patch.dict(os.environ, env, clear=True), + mock.patch.object(sys, "platform", "linux"), + mock.patch("ssl.SSLContext.load_default_certs") as mock_default, + mock.patch("ssl.SSLContext.load_verify_locations") as mock_verify, + ): + get_ssl_context(None, None, None, None, False, False, False, False) + mock_default.assert_called_once() + mock_verify.assert_not_called() + + def test_bypasses_default_certs_on_windows(self): + # PYTHON-5930: on win32, load_default_certs() merges the OS certificate + # store with SSL_CERT_FILE/SSL_CERT_DIR, so it must be bypassed in favor + # of loading the env vars exclusively. + env = dict(os.environ) + env.pop("SSL_CERT_DIR", None) + env["SSL_CERT_FILE"] = CA_PEM + with ( + mock.patch.dict(os.environ, env, clear=True), + mock.patch.object(sys, "platform", "win32"), + mock.patch("ssl.SSLContext.load_default_certs") as mock_default, + mock.patch("ssl.SSLContext.load_verify_locations") as mock_verify, + ): + get_ssl_context(None, None, None, None, False, False, False, False) + mock_verify.assert_called_once_with(cafile=CA_PEM, capath=None) + mock_default.assert_not_called() + + @unittest.skipUnless(_HAVE_PYOPENSSL, "PyOpenSSL is not available.") + def test_bypasses_default_certs_on_macos_pyopenssl(self): + # PYTHON-5930: on macOS with PyOpenSSL, load_default_certs() merges in + # certifi certs, so it must be bypassed just like on win32. + env = dict(os.environ) + env.pop("SSL_CERT_DIR", None) + env["SSL_CERT_FILE"] = CA_PEM + with ( + mock.patch.dict(os.environ, env, clear=True), + mock.patch.object(sys, "platform", "darwin"), + mock.patch("pymongo.pyopenssl_context.SSLContext.load_default_certs") as mock_default, + mock.patch("pymongo.pyopenssl_context.SSLContext.load_verify_locations") as mock_verify, + ): + get_ssl_context(None, None, None, None, False, False, False, True) + mock_verify.assert_called_once_with(cafile=CA_PEM, capath=None) + mock_default.assert_not_called() + + +if __name__ == "__main__": + unittest.main()