From f69d5777c6820979be32a58cd511398f2dc63474 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 21 Sep 2026 18:42:05 -0400 Subject: [PATCH] fix(ssl): build explicit-bundle contexts without importing truststore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _shared_context imported truststore._ssl_constants unconditionally on the ssl_ca_cert branch. truststore is a >=3.14 dependency, so a 3.11-3.13 bridge install with a provider ssl_ca_cert crashed client construction with ModuleNotFoundError. Only reach for truststore's saved original class when ssl.SSLContext has actually been replaced; otherwise the stdlib class is right there and the private import is never needed. Also pin the test env: the "missing bundle falls back to True" contract inherits the host's SSL_CERT_FILE (NixOS shells export it), which flips the return to the shared platform context — a host dependency, not an order dependency. Add the SSL_CERT_FILE contract as its own test. --- agent/ssl_verify.py | 23 ++++++++++++++---- tests/agent/test_ssl_verify.py | 43 +++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/agent/ssl_verify.py b/agent/ssl_verify.py index 4453ed1366..251513708d 100644 --- a/agent/ssl_verify.py +++ b/agent/ssl_verify.py @@ -73,18 +73,33 @@ _CA_CONTEXTS: dict[str | None, ssl.SSLContext] = {} _CA_CONTEXTS_LOCK = threading.Lock() +def _stdlib_ssl_context_class() -> type[ssl.SSLContext]: + """The un-injected stdlib ``ssl.SSLContext``. + + An explicit bundle must REPLACE OS trust, and truststore's context falls + back to the OS verifier whenever the loaded bundle rejects a chain — so + the bundle context has to be built from the stdlib class. Once injected + (here, or by pm.launch/pm.worker before this module loads) the only + handle on it is truststore's own saved reference; when nothing is + injected — truststore absent or not installed — ``ssl.SSLContext`` is + already the stdlib class and truststore must not be imported at all. + """ + if ssl.SSLContext.__module__ == "ssl": + return ssl.SSLContext + from truststore._ssl_constants import _original_SSLContext + + return _original_SSLContext + + def _shared_context(ca_path: str | None) -> ssl.SSLContext: """A stable context identity lets clients reuse the existing transport pool.""" with _CA_CONTEXTS_LOCK: ctx = _CA_CONTEXTS.get(ca_path) if ctx is None: if ca_path is not None: - from truststore._ssl_constants import _original_SSLContext - - # An explicit bundle replaces OS trust, not augments it. # PROTOCOL_TLS_CLIENT sets hostname checking and CERT_REQUIRED; # assigning the original class's properties after injection recurses. - ctx = _original_SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx = _stdlib_ssl_context_class()(ssl.PROTOCOL_TLS_CLIENT) ctx.load_verify_locations(cafile=ca_path) else: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) diff --git a/tests/agent/test_ssl_verify.py b/tests/agent/test_ssl_verify.py index 361b322223..8457cd41a9 100644 --- a/tests/agent/test_ssl_verify.py +++ b/tests/agent/test_ssl_verify.py @@ -9,13 +9,35 @@ import pytest from agent.ssl_verify import resolve_httpx_verify -def test_missing_explicit_bundle_falls_back_to_the_platform_store(tmp_path, caplog): +@pytest.fixture +def no_ca_env(monkeypatch): + """The developer shell (NixOS exports SSL_CERT_FILE) and the gateway's + own cert export both flip resolve_httpx_verify() from ``True`` to a + shared context. The contract under test is the fallback, so pin the + env the assertion assumes instead of inheriting the host's.""" + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + + +def test_missing_explicit_bundle_falls_back_to_the_platform_store(tmp_path, caplog, no_ca_env): missing = str(tmp_path / "nope.pem") assert resolve_httpx_verify(ca_bundle=missing) is True assert "does not exist" in caplog.text +def test_missing_bundle_under_a_cert_env_var_shares_the_platform_context(tmp_path, monkeypatch): + """With SSL_CERT_FILE exported the platform store is handed over as one + shared context (httpx would otherwise read the env var itself); a missing + bundle must land on that same object, not a second pool.""" + import certifi + + monkeypatch.setenv("SSL_CERT_FILE", certifi.where()) + platform = resolve_httpx_verify() + assert platform is not True + assert resolve_httpx_verify(ca_bundle=str(tmp_path / "nope.pem")) is platform + + @pytest.mark.parametrize("value", [False, "false", "0", "no", "off", "FALSE"]) def test_insecure_disables_verification(value): assert resolve_httpx_verify(ssl_verify=value) is False @@ -51,3 +73,22 @@ with httpx.Client(verify=resolve_httpx_verify()) as client: """], capture_output=True, text=True, timeout=30) assert child.returncode == 0, child.stderr assert "truststore unavailable" in child.stderr + + +def test_explicit_bundle_works_without_truststore(): + """A provider ``ssl_ca_cert`` on an interpreter without truststore + (3.11–3.13 bridge installs) must still yield a verifying context built + on that bundle, not an import error at client construction.""" + import subprocess + import sys + + child = subprocess.run([sys.executable, "-c", """ +import sys, ssl, certifi +sys.modules['truststore'] = None +from agent.ssl_verify import resolve_httpx_verify +ctx = resolve_httpx_verify(ca_bundle=certifi.where()) +assert isinstance(ctx, ssl.SSLContext), ctx +assert ctx.verify_mode == ssl.CERT_REQUIRED and ctx.check_hostname +assert ctx.cert_store_stats()['x509_ca'] > 0 +"""], capture_output=True, text=True, timeout=30) + assert child.returncode == 0, child.stderr