Skip to content

Fix #1632: fall back to Requests' CA bundle when OpenSSL's default certs are empty - #1934

Open
vinayK34 wants to merge 1 commit into
httpie:masterfrom
vinayK34:fix/1632-default-ca-bundle-fallback
Open

Fix #1632: fall back to Requests' CA bundle when OpenSSL's default certs are empty#1934
vinayK34 wants to merge 1 commit into
httpie:masterfrom
vinayK34:fix/1632-default-ca-bundle-fallback

Conversation

@vinayK34

Copy link
Copy Markdown

Fixes #1632

Root cause

httpie/compat.py::ensure_default_certs_loaded() only called
SSLContext.load_default_certs(), which populates the context from OpenSSL's
default verify paths
. On some platforms those paths are empty — notably the
macOS python.org / pyenv builds, where OpenSSL is not wired to the system
keychain and no SSL_CERT_FILE/openssl@3 bundle is in play.

That alone would normally be harmless, because Requests would fall back to its
own certifi-backed bundle. But HTTPie always passes its own SSLContext into
Requests (HTTPieHTTPSAdapter.init_poolmanager), and Requests treats a
caller-supplied context as authoritative:

  • in _urllib3_request_context(), pool_kwargs["ssl_context"] = _preloaded_ssl_context
    (Requests' own certifi-loaded context) is skipped when the poolmanager already
    has an ssl_context — i.e. always, for HTTPie;
  • in cert_verify(), load_verify_locations() is only reached when verify is a
    string path; for the default verify=True it is deliberately skipped as an
    optimization.

So with verify=True (the default), HTTPie ended up performing verification
against a context with zero CA certificates, and every HTTPS request failed
with CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate — while
plain requests in the very same virtualenv worked fine. This also explains why
the reporter's https --verify "$(python -m certifi)" ... workaround succeeded:
passing an explicit path is the one code path that does call
load_verify_locations().

The fix

If the context still has no CA certs after load_default_certs(), fall back to
the CA bundle Requests itself would have used (requests.utils.DEFAULT_CA_BUNDLE_PATH,
i.e. certifi), via extract_zipped_paths() so frozen/zipped installs keep working.
Both file and directory bundles are handled (cafile= / capath=), and a missing
path is tolerated rather than raising.

This is additive and conservative: it only ever runs when the context would
otherwise have had no trust store at all, so platforms where
load_default_certs() already works are completely unaffected.

Verification

All of the following was actually executed in a sandbox (Python 3.14,
httpie 3.2.4, requests 2.32.3 — the exact version from the issue report,
urllib3 2.2.3). The macOS condition was reproduced by pointing
SSL_CERT_FILE/SSL_CERT_DIR at nonexistent paths so OpenSSL's default store
resolves empty while a valid bundle still exists on disk.

1. The real httpie CLI against a real host — before vs. after

# STOCK httpie 3.2.4
$ SSL_CERT_FILE=/nonexistent/cert.pem SSL_CERT_DIR=/nonexistent/certs \
    python -m httpie --print=h GET https://example.com
__main__.py: error: SSLError: HTTPSConnectionPool(host='example.com', port=443):
Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1,
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local
issuer certificate (_ssl.c:1082)')))

# WITH THIS PATCH
$ SSL_CERT_FILE=/nonexistent/cert.pem SSL_CERT_DIR=/nonexistent/certs \
    python -m httpie --print=h GET https://example.com
HTTP/1.1 200 OK
Date: Tue, 11 Aug 2026 16:18:48 GMT
Content-Type: text/html
Server: cloudflare
...

2. Isolated reproduction (local HTTPS server), showing the requests-works /
HTTPie-fails asymmetry from the issue

# BEFORE
=== plain requests (uses DEFAULT_CA_BUNDLE_PATH) ===
PLAIN REQUESTS: 200 ok
=== httpie's HTTPieHTTPSAdapter (custom SSLContext) ===
HTTPIE ADAPTER FAILED: SSLError ... CERTIFICATE_VERIFY_FAILED

# AFTER
=== plain requests (uses DEFAULT_CA_BUNDLE_PATH) ===
PLAIN REQUESTS: 200 ok
=== httpie's HTTPieHTTPSAdapter (custom SSLContext) ===
HTTPIE ADAPTER: 200 ok

3. Edge cases

Case Result
--verify=no still skips verification 200 ok
--verify=<correct bundle path> 200 ok
Context that already has CAs is left untouched before=1 after=1 (no reload/duplication)
Healthy platform (real OpenSSL default paths) 150 CA certs loaded — unchanged behaviour
Bogus/missing DEFAULT_CA_BUNDLE_PATH no exception raised
httpie.ssl_ import + DEFAULT_SSL_CIPHERS_STRING fine (17 ciphers), adapter builds with 150 CAs

flake8 --ignore=E501,W503 clean on the changed file; module doctests pass.

On security: I explicitly A/B-tested whether this makes an explicit
--verify=<unrelated CA> wrongly succeed when the server's CA happens to be in
the default store. It behaves identically before and after this patch (trust is
additive on the shared context in both cases), so this is pre-existing upstream
behaviour and not a regression introduced here — worth noting separately, but out
of scope for this fix.

…ult certs are empty

`ensure_default_certs_loaded()` only called `SSLContext.load_default_certs()`,
which relies on OpenSSL's default verify paths. Those are empty on some
platforms (notably macOS python.org/pyenv builds), and because HTTPie always
passes its own SSLContext, Requests skips its certifi-backed context and
`load_verify_locations()` entirely -- leaving no trust store at all, so every
HTTPS request failed with CERTIFICATE_VERIFY_FAILED even though plain
`requests` worked fine in the same environment.

Now, if the context still has no CA certs after `load_default_certs()`, load
the bundle Requests itself would have used (certifi) via
`DEFAULT_CA_BUNDLE_PATH`, handling both file and directory bundles and
tolerating a missing path.
@vinayK34
vinayK34 marked this pull request as ready for review August 11, 2026 16:19
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