From 3e19fe0d116692e651b9024752e7c1ccfb091447 Mon Sep 17 00:00:00 2001 From: Vinay Kumar Date: Tue, 11 Aug 2026 21:01:38 +0530 Subject: [PATCH 1/2] Fall back to certifi when the OS trust store is empty (#1632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTPie always uses its own `SSLContext` (needed for `--ssl`/`--ciphers`), so Requests intentionally skips loading any CA bundle for `--verify=yes` and we load the defaults ourselves via `ensure_default_certs_loaded()`. That relied solely on `SSLContext.load_default_certs()`, which uses OpenSSL's default verify paths. On the python.org macOS builds those paths are empty, so the context ended up with an *empty* trust store and every HTTPS request failed with CERTIFICATE_VERIFY_FAILED — even though `requests` in the same environment worked fine, because it defaults to the certifi bundle. Now, if the OS trust store yields no certificates, we fall back to `requests.adapters.DEFAULT_CA_BUNDLE_PATH` (certifi), matching what Requests itself trusts by default. --- httpie/compat.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/httpie/compat.py b/httpie/compat.py index d12abcff02..5162b359ba 100644 --- a/httpie/compat.py +++ b/httpie/compat.py @@ -1,7 +1,10 @@ +import os import sys from ssl import SSLContext from typing import Any, Optional, Iterable +from requests.adapters import DEFAULT_CA_BUNDLE_PATH + from httpie.cookies import HTTPieCookiePolicy from http import cookiejar # noqa @@ -103,11 +106,26 @@ def get_dist_name(entry_point: importlib_metadata.EntryPoint) -> Optional[str]: def ensure_default_certs_loaded(ssl_context: SSLContext) -> None: """ - Workaround for a bug in Requests 2.32.3 + Load the default CA certificates into the given SSL context. + + Because we always use our own SSL context (to be able to support + `--ssl` and `--ciphers`), Requests doesn’t load any CA bundle for us + when `--verify=yes` — it assumes a custom context already trusts the + default CAs. See . - See + `SSLContext.load_default_certs()` relies on OpenSSL’s default verify + paths, which are empty on some platforms (most notably the python.org + macOS builds). In that case we fall back to the `certifi` bundle, which + is exactly what Requests itself trusts by default — otherwise HTTPie + would end up with an empty trust store and fail to verify any + certificate. See . """ - if hasattr(ssl_context, 'load_default_certs'): - if not ssl_context.get_ca_certs(): - ssl_context.load_default_certs() + if not hasattr(ssl_context, 'load_default_certs'): + return + + if not ssl_context.get_ca_certs(): + ssl_context.load_default_certs() + + if not ssl_context.get_ca_certs() and os.path.exists(DEFAULT_CA_BUNDLE_PATH): + ssl_context.load_verify_locations(cafile=DEFAULT_CA_BUNDLE_PATH) From c19fb229527315b481579981b2926a17bf4bb2af Mon Sep 17 00:00:00 2001 From: Vinay Kumar Date: Tue, 11 Aug 2026 21:01:57 +0530 Subject: [PATCH 2/2] Add regression tests for the certifi trust-store fallback (#1632) `test_ensure_default_certs_loaded_falls_back_to_certifi` fails against the previous implementation (empty trust store) and passes with the fix. --- tests/test_compat.py | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/test_compat.py diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000000..37673ebb6a --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,62 @@ +import os +import ssl + +from unittest import mock + +from requests.adapters import DEFAULT_CA_BUNDLE_PATH + +from httpie.compat import ensure_default_certs_loaded + + +def _empty_trust_store_env(tmp_path): + """Point OpenSSL’s default verify paths at an empty location. + + This emulates the python.org macOS builds, where the OS trust store is + not reachable through OpenSSL’s defaults. + """ + return { + 'SSL_CERT_FILE': str(tmp_path / '__no_such_ca_bundle__.pem'), + 'SSL_CERT_DIR': str(tmp_path), + } + + +def test_ensure_default_certs_loaded_from_os_trust_store(): + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ensure_default_certs_loaded(ssl_context) + assert ssl_context.get_ca_certs() + + +def test_ensure_default_certs_loaded_falls_back_to_certifi(tmp_path): + """""" + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + with mock.patch.dict(os.environ, _empty_trust_store_env(tmp_path)): + ssl_context.load_default_certs() + assert not ssl_context.get_ca_certs(), ( + 'precondition: the OS trust store must be empty for this test' + ) + ensure_default_certs_loaded(ssl_context) + assert ssl_context.get_ca_certs(), ( + 'the certifi bundle should have been loaded as a fallback' + ) + + +def test_ensure_default_certs_loaded_keeps_already_loaded_certs(): + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.load_verify_locations(cafile=DEFAULT_CA_BUNDLE_PATH) + already_loaded = ssl_context.get_ca_certs() + with mock.patch.object(ssl_context, 'load_default_certs') as load_default_certs: + ensure_default_certs_loaded(ssl_context) + load_default_certs.assert_not_called() + assert ssl_context.get_ca_certs() == already_loaded + + +def test_ensure_default_certs_loaded_without_any_bundle_available(tmp_path): + """A missing certifi bundle must not turn into a hard error.""" + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + with mock.patch.dict(os.environ, _empty_trust_store_env(tmp_path)), \ + mock.patch( + 'httpie.compat.DEFAULT_CA_BUNDLE_PATH', + str(tmp_path / '__no_such_certifi_bundle__.pem'), + ): + ensure_default_certs_loaded(ssl_context) + assert not ssl_context.get_ca_certs()