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) 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()