PYTHON-5930 Fix SSL_CERT_FILE/SSL_CERT_DIR trust store handling#2925
PYTHON-5930 Fix SSL_CERT_FILE/SSL_CERT_DIR trust store handling#2925blink1073 wants to merge 11 commits into
Conversation
Honor SSL_CERT_FILE/SSL_CERT_DIR as the exclusive trust source across platforms instead of merging them with the OS/certifi certificate store.
…+PyOpenSSL Only bypass load_default_certs() where it actually merges an OS/certifi store (win32, and darwin with PyOpenSSL). Everywhere else, load_default_certs() already honors these env vars correctly, including falling back to the platform default for whichever of the two is left unset -- calling load_verify_locations() unconditionally dropped that fallback, silently narrowing the trust store e.g. on Linux when only one var was set. Adds regression tests covering both branches.
…g host create_pyopenssl_variants() never passed host=host to create_variant(), unlike create_encryption_variants(). The win64 case worked by accident via create_variant()'s display-name-based win64 special case, but pyopenssl-macos silently fell back to the rhel87-small default and never actually ran on macOS, so it couldn't validate macOS-specific PyOpenSSL behavior.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR addresses PYTHON-5930 by correcting how PyMongo constructs its TLS trust store when SSL_CERT_FILE/SSL_CERT_DIR are set, ensuring those env vars replace (not merge with) the OS/certifi store on affected platform/backend combinations. It also fixes an Evergreen variant-generation issue so the PyOpenSSL macOS variant actually runs on a macOS host.
Changes:
- Adjusted
get_ssl_context()to bypassload_default_certs()only on Windows and on macOS when using PyOpenSSL whenSSL_CERT_FILE/SSL_CERT_DIRare set. - Added regression tests for Windows bypass behavior and for preserving default fallback behavior off Windows.
- Fixed Evergreen PyOpenSSL variant generation by correctly passing the host through, and updated the generated variants YAML accordingly.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pymongo/ssl_support.py |
Implements conditional bypass of load_default_certs() so SSL_CERT_FILE/SSL_CERT_DIR replace the trust store on Windows and macOS+PyOpenSSL. |
test/asynchronous/test_ssl.py |
Adds unit tests around SSL_CERT_FILE behavior (Windows bypass + non-Windows fallback). |
test/test_ssl.py |
Sync-mirrored equivalent of the new SSL env-var tests. |
doc/changelog.rst |
Documents the Windows/macOS+PyOpenSSL trust-store merge bugfix. |
.evergreen/scripts/generate_config.py |
Fixes PyOpenSSL variant generation by passing host=host to create_variant(). |
.evergreen/generated_configs/variants.yml |
Updates the generated config so “PyOpenSSL macOS” runs on macos-14 instead of a RHEL host. |
Addresses review feedback: the darwin+PyOpenSSL bypass path had no direct test coverage, only the win32 bypass and the non-Windows fallback were exercised.
|
I'm investigating the ECS failure separately. It is failing on the waterfall as well. |
| def test_use_pyopenssl_when_available(self): | ||
| self.assertTrue(HAVE_PYSSL) | ||
|
|
||
| def test_ssl_cert_file_env_var_preserves_default_fallback_off_windows(self): |
There was a problem hiding this comment.
This test has "windows" in the name but tests correct behavior for Linux? The comment is also unclear, I don't really follow.
There was a problem hiding this comment.
Renamed to test_ssl_cert_file_env_var_uses_default_certs_on_linux and simplified the comment.
| mock_verify.assert_not_called() | ||
|
|
||
| def test_ssl_cert_file_env_var_bypasses_default_certs_on_windows(self): | ||
| # PYTHON-5930: on win32 (and macOS+PyOpenSSL), load_default_certs() merges |
There was a problem hiding this comment.
I don't think this comment needs to mention macOS + PyOpenSSL since the following test does.
There was a problem hiding this comment.
Dropped the macOS+PyOpenSSL mention from this comment, good catch.
| def test_use_pyopenssl_when_available(self): | ||
| self.assertTrue(HAVE_PYSSL) | ||
|
|
||
| def test_ssl_cert_file_env_var_preserves_default_fallback_off_windows(self): |
There was a problem hiding this comment.
These tests don't need to be async/sync, can we consolidate into a single shared test file?
There was a problem hiding this comment.
I think that should be deferred to the code consolidation work
There was a problem hiding this comment.
I'd prefer not to add code that we know is already duplicated at creation.
There was a problem hiding this comment.
Went further than just the 3 SSL_CERT_FILE tests: moved the entire TestClientSSL class (all pure unit tests, no async dependency) to a new non-mirrored test/test_ssl_support.py, following the existing test_pyopenssl_context.py precedent for standalone test files. Removed it from both test/asynchronous/test_ssl.py and the generated test/test_ssl.py.
There was a problem hiding this comment.
Fair enough, I moved all the unit tests to a new module
Co-authored-by: Noah Stapp <noah@noahstapp.com>
Addresses review feedback: rename the Linux fallback test to name the platform it actually covers, and drop the redundant macOS+PyOpenSSL mention from the Windows test's comment since a dedicated test now covers that case.
Addresses review feedback: the tests in TestClientSSL are pure unit tests (mocked or manual asyncio.run()) with no dependency on the async/sync test class machinery, so they were needlessly duplicated by synchro. Moved them to test/test_ssl_support.py, following the existing test_pyopenssl_context.py precedent for standalone test files that aren't generated/mirrored.
| CRL_PEM = os.path.join(CERT_PATH, "crl.pem") | ||
|
|
||
|
|
||
| class TestClientSSL(unittest.TestCase): |
There was a problem hiding this comment.
Shouldn't every test in this class (except the first) have @unittest.skipUnless(HAVE_SSL) or @unittest.skipUnless(_HAVE_PYOPENSSL)?
| CERT_PATH = os.path.join(pathlib.Path(__file__).resolve().parent, "certificates") | ||
| CLIENT_PEM = os.path.join(CERT_PATH, "client.pem") | ||
| CA_PEM = os.path.join(CERT_PATH, "ca.pem") | ||
| CRL_PEM = os.path.join(CERT_PATH, "crl.pem") |
There was a problem hiding this comment.
CERT_PATH, CLIENT_PATH, and CA_PEM are all already defined in helpers_shared.py.
| mock_verify.assert_called_once_with(cafile=CA_PEM, capath=None) | ||
| mock_default.assert_not_called() | ||
|
|
||
| def test_ssl_session_cache(self): |
There was a problem hiding this comment.
This test doesn't appear to test anything beside list behavior.
| @unittest.skipIf(HAVE_SSL, "The ssl module is available, can't test what happens without it.") | ||
| def test_no_ssl_module(self): | ||
| # Explicit | ||
| self.assertRaises(ConfigurationError, MongoClient, ssl=True) |
There was a problem hiding this comment.
Prefer using PyMongoTestCase.simple_client here to avoid possible leaks if an assertion fails.
| CRL_PEM = os.path.join(CERT_PATH, "crl.pem") | ||
|
|
||
|
|
||
| class TestClientSSL(unittest.TestCase): |
There was a problem hiding this comment.
| class TestClientSSL(unittest.TestCase): | |
| class TestClientSSL(PyMongoTestCase): |
Co-authored-by: Noah Stapp <noah@noahstapp.com>
|
Making a new file was a mistake, I'm going to revert it. This is exactly the scope creep I was trying to avoid. |
… file" This reverts commit 9e55d86.
Can we move just the new tests there and open a follow-up ticket to refactor the existing test suite? Avoids scope creep but doesn't add known tech debt here. |
PYTHON-5930
Changes in this PR
Fixes a test failure caused by
SSL_CERT_FILE/SSL_CERT_DIRbeing merged with the OS/certifi certificate store instead of replacing it, on Windows and on macOS when using PyOpenSSL. The fix is scoped to just those platform/backend combinations, since elsewhereload_default_certs()already honors these env vars correctly (including falling back to the platform default for whichever of the two is left unset).Also fixes an unrelated Evergreen config bug found while validating this change: the
pyopenssl-macosvariant never actually ran on macOS.create_pyopenssl_variants()wasn't passinghost=hosttocreate_variant(), so it silently fell back to a RHEL host. Thepyopenssl-win64variant only worked by accident because of a separate win64 name-based special case. Without this fix, the macOS+PyOpenSSL half of this change could never have been verified in CI.Test Plan
Ran
just typingandjust lint; both pass. Added unit tests covering both the Windows bypass and the preserved fallback behavior elsewhere. Confirmed with an Evergreen patch build acrosstest-win64,pyopenssl-win64, andpyopenssl-macos(now genuinely running onmacos-14): all tasks passed — patch #10613.Checklist
Checklist for Author
Checklist for Reviewer