Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .evergreen/generated_configs/variants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .evergreen/scripts/generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ def create_pyopenssl_variants():
create_variant(
tasks,
display_name,
host=host,
expansions=expansions,
batchtime=batchtime,
)
Expand Down
3 changes: 3 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
--------------------------------------
Expand Down
14 changes: 13 additions & 1 deletion pymongo/ssl_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

from __future__ import annotations

import os
import sys
import types
import warnings
from typing import Any, Optional, Union
Expand Down Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions test/test_ssl_support.py
Original file line number Diff line number Diff line change
@@ -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()
Loading