"""Unit tests for headroom.proxy.ssl_context.find_ca_bundle. Covers: - Returns None when no env var is set - Returns an ssl.SSLContext when SSL_CERT_FILE points to a valid PEM file - Returns an ssl.SSLContext when REQUESTS_CA_BUNDLE points to a valid PEM file - Replacement contexts relax OpenSSL VERIFY_X509_STRICT for custom CA bundles - Returns an ssl.SSLContext when NODE_EXTRA_CA_CERTS points to a valid PEM file - The NODE_EXTRA_CA_CERTS SSLContext is additive: default/system roots are preserved (#998) - Priority order: SSL_CERT_FILE beats REQUESTS_CA_BUNDLE beats NODE_EXTRA_CA_CERTS - Nonexistent paths are skipped (returns None if all paths are missing) """ from __future__ import annotations import ssl import pytest from headroom.proxy import ssl_context from headroom.proxy.ssl_context import ( apply_global_tls_relaxation, build_httpx_verify, find_ca_bundle, tls_strict_disabled, ) # Minimal self-signed CA certificate (PEM) used only to verify that # load_verify_locations accepts the file. Generated offline; never used # for real TLS handshakes in these tests. _SELF_SIGNED_CA_PEM = b"""\ -----BEGIN CERTIFICATE----- MIIDFzCCAf+gAwIBAgIUWP49K8QzU5B68/BZSmeqPCDaBoQwDQYJKoZIhvcNAQEL BQAwGzEZMBcGA1UEAwwQaGVhZHJvb20tdGVzdC1jYTAeFw0yNjA2MDgxNDIwMzFa Fw0zNjA2MDUxNDIwMzFaMBsxGTAXBgNVBAMMEGhlYWRyb29tLXRlc3QtY2EwggEi MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvTqYZXAhet9yw1n4cFeC8HosC 1Od/bibXyW7ko7aOuuzUT7B9l7MwDfgrE2mjHecoSe2qbknFcv6hxbYojh4J7C8r UPgCA2QTtU3pBxQdwO156YAOmFPuBFPb19NAErOVlnHCU+NXCVSsE5y+AJjM161S W0HnZgO8OADZHBs5jSAGDE3ymMw+8xpuvRKJnuvK0Tcu6bOqOTMbnggwmPBZBBLW PrurPTN0vV9C2oyHA1tXgEJyYtEPoMfaqyE80GxYeUujt9EQWrLp+3k8ufB/yJ1b DaSrH0GZYx2HUn0p1mqWzXcKZrSrL1o+38gCmCivG0movXt6z1tUly8mTGz/AgMB AAGjUzBRMB0GA1UdDgQWBBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAfBgNVHSMEGDAW gBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 DQEBCwUAA4IBAQAb44h2gg9wWU5todvwSXVAlBb/WZD1l/NG2PeTsGoH7xqmfgq9 DxV6tvoIuDlu6OKz071ljSqRh0Mesh1ma1cj6snsc/jqgsakSlcOpOCsrTCvw2DB 2oTztHnO4PiZAPtuKiawhVQpJfEna9/xOkbalazecSGngtSzd/oIJEXe299hE1/1 Tfx2hBGZ0UogmREaXFi099rmaueZ0HIBn51b3kYqc7of5TI0fHwSHF4GdXXs2OZi 6EVQWhKx5nQbklTYP5/ge9olEIsMdGqJEiz7WfSC6QBBgvoYyH596GiSGRZcX67p kF9agIt8Q8t/2kviMn2roInGTwTyPYOEQV0m -----END CERTIFICATE----- """ @pytest.fixture() def ca_pem_file(tmp_path): """Write the self-signed CA PEM to a temp file and return its path.""" p = tmp_path / "ca.pem" p.write_bytes(_SELF_SIGNED_CA_PEM) return str(p) def _clean_env(monkeypatch): """Remove all CA-bundle env vars + the strict toggle for a clean state.""" for var in ( "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", "HEADROOM_TLS_STRICT", ): monkeypatch.delenv(var, raising=False) class FakeSSLContext: def __init__(self, verify_flags: int = 0) -> None: self.verify_flags = verify_flags self.loaded_cafile: str | None = None self.alpn_protocols: list[str] | None = None def load_verify_locations(self, *, cafile: str) -> None: self.loaded_cafile = cafile def set_alpn_protocols(self, protocols: list[str]) -> None: self.alpn_protocols = protocols class TestFindCaBundleNoEnvVars: def test_returns_none_when_no_env_var_set(self, monkeypatch): _clean_env(monkeypatch) assert find_ca_bundle() is None class TestFindCaBundleWithValidPem: def test_ssl_cert_file_returns_ssl_context(self, monkeypatch, ca_pem_file): _clean_env(monkeypatch) monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file) ctx = find_ca_bundle() assert isinstance(ctx, ssl.SSLContext) def test_requests_ca_bundle_returns_ssl_context(self, monkeypatch, ca_pem_file): _clean_env(monkeypatch) monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file) ctx = find_ca_bundle() assert isinstance(ctx, ssl.SSLContext) def test_replacement_ca_context_relaxes_x509_strict(self, monkeypatch, ca_pem_file): _clean_env(monkeypatch) monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file) strict_flag = 0x20 created_context = FakeSSLContext(verify_flags=strict_flag | 0x100) def fake_create_default_context(*, cafile: str | None = None): assert cafile == ca_pem_file return created_context monkeypatch.setattr(ssl_context.ssl, "VERIFY_X509_STRICT", strict_flag, raising=False) monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context) ctx = find_ca_bundle() assert ctx is created_context assert created_context.verify_flags & strict_flag == 0 assert created_context.verify_flags & 0x100 assert created_context.alpn_protocols == ["h2", "http/1.1"] def test_node_extra_ca_certs_returns_ssl_context(self, monkeypatch, ca_pem_file): """NODE_EXTRA_CA_CERTS returns an SSLContext, not a bare path (#998).""" _clean_env(monkeypatch) monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file) ctx = find_ca_bundle() assert isinstance(ctx, ssl.SSLContext) def test_node_extra_ca_certs_is_additive(self, monkeypatch, ca_pem_file): """The SSLContext must contain default/system roots plus the extra cert (#998).""" _clean_env(monkeypatch) monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file) ctx = find_ca_bundle() assert isinstance(ctx, ssl.SSLContext) stats = ctx.cert_store_stats() # The default trust store has dozens of CAs; if only the test cert # were loaded (replacement), x509_ca would be 1. assert stats["x509_ca"] > 1 class TestFindCaBundlePriority: def test_ssl_cert_file_beats_requests_ca_bundle(self, monkeypatch, tmp_path): """SSL_CERT_FILE is used first even when REQUESTS_CA_BUNDLE is also set.""" _clean_env(monkeypatch) pem1 = tmp_path / "first.pem" pem2 = tmp_path / "second.pem" pem1.write_bytes(_SELF_SIGNED_CA_PEM) pem2.write_bytes(_SELF_SIGNED_CA_PEM) monkeypatch.setenv("SSL_CERT_FILE", str(pem1)) monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem2)) created_context = FakeSSLContext() def fake_create_default_context(*, cafile: str | None = None): assert cafile == str(pem1) return created_context monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context) assert find_ca_bundle() is created_context def test_ssl_cert_file_beats_node_extra_ca_certs(self, monkeypatch, tmp_path): """SSL_CERT_FILE takes precedence over NODE_EXTRA_CA_CERTS.""" _clean_env(monkeypatch) pem = tmp_path / "ca.pem" pem.write_bytes(_SELF_SIGNED_CA_PEM) monkeypatch.setenv("SSL_CERT_FILE", str(pem)) monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem") created_context = FakeSSLContext() def fake_create_default_context(*, cafile: str | None = None): assert cafile == str(pem) return created_context monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context) assert find_ca_bundle() is created_context def test_requests_ca_bundle_beats_node_extra_ca_certs(self, monkeypatch, tmp_path): """REQUESTS_CA_BUNDLE is used before NODE_EXTRA_CA_CERTS.""" _clean_env(monkeypatch) pem = tmp_path / "ca.pem" pem.write_bytes(_SELF_SIGNED_CA_PEM) monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem") monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem)) monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem") created_context = FakeSSLContext() def fake_create_default_context(*, cafile: str | None = None): assert cafile == str(pem) return created_context monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context) assert find_ca_bundle() is created_context class TestFindCaBundleNonexistentPaths: def test_nonexistent_path_is_skipped(self, monkeypatch): _clean_env(monkeypatch) monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/path/ca.pem") assert find_ca_bundle() is None def test_all_nonexistent_returns_none(self, monkeypatch): _clean_env(monkeypatch) monkeypatch.setenv("SSL_CERT_FILE", "/no/such/file1.pem") monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/no/such/file2.pem") monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/no/such/file3.pem") assert find_ca_bundle() is None def test_first_nonexistent_falls_through_to_valid(self, monkeypatch, ca_pem_file): """When the first env var path is missing, the next valid one is used.""" _clean_env(monkeypatch) monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem") monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file) ctx = find_ca_bundle() assert isinstance(ctx, ssl.SSLContext) # --------------------------------------------------------------------------- # HEADROOM_TLS_STRICT toggle (issue #1308): corporate TLS-inspection roots # (Zscaler, Netskope) set CA:TRUE without the critical bit, which Python 3.13 # + OpenSSL 3.x reject under VERIFY_X509_STRICT. A CA bundle can't fix that — # the cert is found, the strict check fails. The toggle clears only the strict # flag, on both the httpx upstream path and the urllib3/huggingface path. # --------------------------------------------------------------------------- class TestTlsStrictDisabled: @pytest.mark.parametrize("val", ["0", "false", "FALSE", "No", "off", " off "]) def test_off_values_disable_strict(self, monkeypatch, val): _clean_env(monkeypatch) monkeypatch.setenv("HEADROOM_TLS_STRICT", val) assert tls_strict_disabled() is True @pytest.mark.parametrize("val", ["1", "true", "yes", "on", "", "strict", "00"]) def test_other_values_keep_strict(self, monkeypatch, val): _clean_env(monkeypatch) monkeypatch.setenv("HEADROOM_TLS_STRICT", val) assert tls_strict_disabled() is False def test_unset_keeps_strict(self, monkeypatch): _clean_env(monkeypatch) assert tls_strict_disabled() is False class TestBuildHttpxVerify: def test_default_returns_true(self, monkeypatch): """No CA bundle, strict on → httpx's own default verification.""" _clean_env(monkeypatch) assert build_httpx_verify() is True def test_toggle_off_returns_relaxed_context(self, monkeypatch): """No CA bundle, strict OFF → default trust store with strict cleared.""" _clean_env(monkeypatch) monkeypatch.setenv("HEADROOM_TLS_STRICT", "0") ctx = build_httpx_verify() assert isinstance(ctx, ssl.SSLContext) strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0) if strict_flag: assert ctx.verify_flags & strict_flag == 0 # Still a real verifying context — NOT verify=False. assert ctx.verify_mode == ssl.CERT_REQUIRED # Default trust store retained (additive, not a 1-cert replacement). assert ctx.cert_store_stats()["x509_ca"] > 1 def test_custom_ca_takes_precedence_over_toggle(self, monkeypatch, ca_pem_file): """A configured CA bundle wins; the result is that bundle's context.""" _clean_env(monkeypatch) monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file) monkeypatch.setenv("HEADROOM_TLS_STRICT", "0") ctx = build_httpx_verify() assert isinstance(ctx, ssl.SSLContext) # Replacement bundle → only the single test CA is trusted. assert ctx.cert_store_stats()["x509_ca"] == 1 class TestApplyGlobalTlsRelaxation: def test_noop_when_strict_on(self, monkeypatch): _clean_env(monkeypatch) assert apply_global_tls_relaxation() is False def test_patches_urllib3_when_toggle_off(self, monkeypatch): _clean_env(monkeypatch) monkeypatch.setenv("HEADROOM_TLS_STRICT", "0") strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0) if not strict_flag: pytest.skip("VERIFY_X509_STRICT unavailable on this OpenSSL build") import urllib3.util.ssl_ as u3ssl original = u3ssl.create_urllib3_context try: assert apply_global_tls_relaxation() is True ctx = u3ssl.create_urllib3_context() assert ctx.verify_flags & strict_flag == 0 # Idempotent: second call doesn't re-wrap or error. assert apply_global_tls_relaxation() is True assert getattr(u3ssl.create_urllib3_context, "_headroom_strict_relaxed", False) finally: u3ssl.create_urllib3_context = original