Skip to content

Fix #1632: fall back to certifi when the platform trust store is empty - #1932

Open
vinayK34 wants to merge 1 commit into
httpie:masterfrom
vinayK34:fix/1632-fallback-to-certifi-when-os-trust-store-empty
Open

Fix #1632: fall back to certifi when the platform trust store is empty#1932
vinayK34 wants to merge 1 commit into
httpie:masterfrom
vinayK34:fix/1632-fallback-to-certifi-when-os-trust-store-empty

Conversation

@vinayK34

Copy link
Copy Markdown

Summary

Fixes #1632 β€” HTTPie fails TLS verification on macOS unless a CA bundle is passed explicitly, even though requests works fine in the exact same environment.

Root cause

HTTPieHTTPSAdapter._create_ssl_context() always builds a custom SSLContext and hands it to urllib3. That single fact disables both of the automatic certifi paths that normally save you:

  1. urllib3 only calls context.load_default_certs() when it created the context itself β€” the check in _ssl_wrap_socket_and_match_hostname() is gated on and default_ssl_context. Since HTTPie supplies one, urllib3 skips it.
  2. Requests has a preloaded, certifi-backed context (_preloaded_ssl_context, loaded via load_verify_locations(DEFAULT_CA_BUNDLE_PATH)), but _urllib3_request_context() only uses it when not has_poolmanager_ssl_context. HTTPie sets one, so Requests skips that too. And with verify=True, cert_verify() deliberately loads nothing ("the connection will be using a context with the default certificates already loaded").

So the only thing left is HTTPie's own ensure_default_certs_loaded(), which calls load_default_certs() and nothing more. On python.org macOS builds OpenSSL's default trust store is empty β€” certifi is the trust store β€” so load_default_certs() loads zero certificates, and the context goes out with an empty CA set while cert_reqs=CERT_REQUIRED. Every verification fails.

Plain requests is unaffected precisely because it does reach its certifi-preloaded context. Hence the issue's observation: same venv, requests works, https doesn't, and --verify "$(python -m certifi)" fixes it.

Fix

ensure_default_certs_loaded() now falls back to the same CA bundle Requests itself would have used (certifi, via requests.utils.DEFAULT_CA_BUNDLE_PATH + extract_zipped_paths) when β€” and only when β€” load_default_certs() leaves the context with no CA certificates. Scoped to httpie/compat.py; the #1583 behavior is preserved as the first branch.

Deliberately conservative: it only engages on an otherwise-empty trust store, reuses Requests' own bundle resolution rather than importing certifi directly (respecting REQUESTS_CA_BUNDLE/vendored setups), handles the capath case, and degrades to a no-op if certifi/Requests isn't importable.

Verification

All run in a sandbox against real endpoints. The macOS condition was reproduced on Linux by pointing SSL_CERT_FILE/SSL_CERT_DIR at an empty file/dir, making OpenSSL's default store empty exactly as on python.org macOS builds (load_default_certs() β†’ 0 certs, confirmed directly).

Before (httpie 3.2.4, requests 2.32.3 β€” the reporter's versions):

--- plain requests ---
requests OK 200
--- httpie 3.2.4 ---
error: SSLError: ... [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1082) ... https://example.com/

Same divergence as the issue: requests 200, HTTPie CERTIFICATE_VERIFY_FAILED.

After, re-tested from the content actually committed to this branch, on a clean httpie==3.2.4 install (baseline re-confirmed failing first):

A) issue reproduction (empty OS trust store)   -> HTTP/1.1 301 Moved Permanently  (https get google.com)
B) SSL_CERT_DIR real dir, cafile empty         -> HTTP/1.1 200 OK
C) self-signed still rejected                  -> CERTIFICATE_VERIFY_FAILED
D) expired cert still rejected                 -> CERTIFICATE_VERIFY_FAILED
E) --verify=no                                 -> HTTP/1.1 200 OK
F) --ssl=tls1.2 with fallback bundle           -> HTTP/1.1 200 OK
G) normal env (populated OS store) unchanged   -> HTTP/1.1 200 OK

C and D are the important ones: the fallback restores a correct trust store, it does not weaken verification.

Existing behavior covered by tests/test_ssl.py, checked against a self-signed pytest_httpbin secure server:

1) --verify=CA_BUNDLE against self-signed          -> HTTP/1.1 200 OK
2) default verify against self-signed (must fail)  -> FAILED-as-expected
3) --verify=no against self-signed                 -> HTTP/1.1 200 OK

--verify=<bad bundle> was also diffed against unpatched compat.py and is byte-identical (NO_CERTIFICATE_OR_CRL_FOUND both ways), and the #1583 case (verify=True β†’ CA certs present) plus get_default_ciphers_names() still behave.

Notes

No new dependency β€” certifi is already an indirect dependency via Requests, and it's reached through Requests' own resolution rather than a direct import.

On platforms where OpenSSL's default trust store is empty (notably
python.org macOS builds, which rely on certifi instead), HTTPie could
not verify any TLS certificate by default, even though `requests` in
the very same environment worked fine.

Because HTTPie always hands a custom SSLContext to urllib3, urllib3
skips its own `load_default_certs()` path (it only does that for
contexts it created itself), and Requests likewise skips preloading
its certifi-backed context. The result is a context with zero CA
certificates whenever `load_default_certs()` finds nothing.

`ensure_default_certs_loaded()` now falls back to the same CA bundle
Requests would have used (certifi) when the platform default store
turns out to be empty, so HTTPie is never less capable than requests.
Explicit `--verify=<path>` and `--verify=no` behavior is unchanged, and
untrusted/self-signed certificates are still rejected.
@vinayK34
vinayK34 marked this pull request as ready for review August 11, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTPie does not work on macOS unless certificate bundle is explicitly specified, even though requests does

1 participant