From 635c68bdbb7f19a2151efbbae1afa0db17503e1b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 1 Jun 2026 12:20:00 +0200 Subject: [PATCH 1/4] Switch httpcore2 from `certifi` to `truststore` for default SSL verification Mirrors #209, which only switched httpx2. httpcore2 still declared `certifi` directly and loaded it in `default_ssl_context()`, so it was installed and imported even though httpx2 always passes its own truststore context down. `certifi` now remains only as a transitive test dependency of `requests`. --- src/httpcore2/httpcore2/_ssl.py | 6 ++---- src/httpcore2/pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/httpcore2/httpcore2/_ssl.py b/src/httpcore2/httpcore2/_ssl.py index c99c5a67..fd564808 100644 --- a/src/httpcore2/httpcore2/_ssl.py +++ b/src/httpcore2/httpcore2/_ssl.py @@ -1,9 +1,7 @@ import ssl -import certifi +import truststore def default_ssl_context() -> ssl.SSLContext: - context = ssl.create_default_context() - context.load_verify_locations(certifi.where()) - return context + return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) diff --git a/src/httpcore2/pyproject.toml b/src/httpcore2/pyproject.toml index cc857497..cf30fc87 100644 --- a/src/httpcore2/pyproject.toml +++ b/src/httpcore2/pyproject.toml @@ -41,7 +41,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP", ] -dependencies = ["certifi", "h11>=0.16"] +dependencies = ["truststore>=0.10", "h11>=0.16"] [project.optional-dependencies] http2 = ["h2>=3,<5"] diff --git a/uv.lock b/uv.lock index 3bbb1181..9e017891 100644 --- a/uv.lock +++ b/uv.lock @@ -1317,8 +1317,8 @@ wheels = [ name = "httpcore2" source = { editable = "src/httpcore2" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] [package.optional-dependencies] @@ -1338,11 +1338,11 @@ trio = [ [package.metadata] requires-dist = [ { name = "anyio", marker = "extra == 'asyncio'", specifier = ">=4.5.0,<5.0" }, - { name = "certifi" }, { name = "h11", specifier = ">=0.16" }, { name = "h2", marker = "extra == 'http2'", specifier = ">=3,<5" }, { name = "socksio", marker = "extra == 'socks'", specifier = "==1.*" }, { name = "trio", marker = "extra == 'trio'", specifier = ">=0.22.0,<1.0" }, + { name = "truststore", specifier = ">=0.10" }, ] provides-extras = ["asyncio", "http2", "socks", "trio"] From b3fd1b9a9838941f8136a7065508e1bb3d761fae Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 1 Jun 2026 12:27:30 +0200 Subject: [PATCH 2/4] Honor `SSL_CERT_FILE`/`SSL_CERT_DIR` in `default_ssl_context()` Bare `truststore.SSLContext` ignores the OpenSSL CA env overrides, so direct httpcore2 users relying on them for a corporate/private CA would lose verification. Mirror the `httpx2.create_ssl_context()` fallback. --- src/httpcore2/httpcore2/_ssl.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/httpcore2/httpcore2/_ssl.py b/src/httpcore2/httpcore2/_ssl.py index fd564808..091e03a9 100644 --- a/src/httpcore2/httpcore2/_ssl.py +++ b/src/httpcore2/httpcore2/_ssl.py @@ -1,7 +1,12 @@ +import os import ssl import truststore def default_ssl_context() -> ssl.SSLContext: + if cafile := os.environ.get("SSL_CERT_FILE"): + return ssl.create_default_context(cafile=cafile) + if capath := os.environ.get("SSL_CERT_DIR"): + return ssl.create_default_context(capath=capath) return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) From fbea9a334cda5d1b22a95c72fe20aade553c8d15 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 1 Jun 2026 12:30:14 +0200 Subject: [PATCH 3/4] Test `default_ssl_context()` env overrides and default --- tests/httpcore2/test_ssl.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/httpcore2/test_ssl.py diff --git a/tests/httpcore2/test_ssl.py b/tests/httpcore2/test_ssl.py new file mode 100644 index 00000000..34c2b83c --- /dev/null +++ b/tests/httpcore2/test_ssl.py @@ -0,0 +1,29 @@ +import ssl +from pathlib import Path + +import certifi +import pytest +import truststore + +from httpcore2._ssl import default_ssl_context + + +def test_default_ssl_context() -> None: + context = default_ssl_context() + assert isinstance(context, truststore.SSLContext) + assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED + + +def test_default_ssl_context_with_cert_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SSL_CERT_FILE", certifi.where()) + context = default_ssl_context() + assert isinstance(context, ssl.SSLContext) + assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED + + +def test_default_ssl_context_with_cert_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.setenv("SSL_CERT_DIR", str(tmp_path)) + context = default_ssl_context() + assert isinstance(context, ssl.SSLContext) + assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED From 7930a59f6cdde5293cc68b631a9282a7372fda25 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 1 Jun 2026 12:32:29 +0200 Subject: [PATCH 4/4] Exclude `SSL_CERT_FILE`/`SSL_CERT_DIR` branches from coverage Match httpx2's `# pragma: no cover` precedent for the env-override paths instead of testing them. --- src/httpcore2/httpcore2/_ssl.py | 4 ++-- tests/httpcore2/test_ssl.py | 29 ----------------------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 tests/httpcore2/test_ssl.py diff --git a/src/httpcore2/httpcore2/_ssl.py b/src/httpcore2/httpcore2/_ssl.py index 091e03a9..c937023f 100644 --- a/src/httpcore2/httpcore2/_ssl.py +++ b/src/httpcore2/httpcore2/_ssl.py @@ -5,8 +5,8 @@ def default_ssl_context() -> ssl.SSLContext: - if cafile := os.environ.get("SSL_CERT_FILE"): + if cafile := os.environ.get("SSL_CERT_FILE"): # pragma: no cover return ssl.create_default_context(cafile=cafile) - if capath := os.environ.get("SSL_CERT_DIR"): + if capath := os.environ.get("SSL_CERT_DIR"): # pragma: no cover return ssl.create_default_context(capath=capath) return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) diff --git a/tests/httpcore2/test_ssl.py b/tests/httpcore2/test_ssl.py deleted file mode 100644 index 34c2b83c..00000000 --- a/tests/httpcore2/test_ssl.py +++ /dev/null @@ -1,29 +0,0 @@ -import ssl -from pathlib import Path - -import certifi -import pytest -import truststore - -from httpcore2._ssl import default_ssl_context - - -def test_default_ssl_context() -> None: - context = default_ssl_context() - assert isinstance(context, truststore.SSLContext) - assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED - - -def test_default_ssl_context_with_cert_file(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("SSL_CERT_FILE", certifi.where()) - context = default_ssl_context() - assert isinstance(context, ssl.SSLContext) - assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED - - -def test_default_ssl_context_with_cert_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.delenv("SSL_CERT_FILE", raising=False) - monkeypatch.setenv("SSL_CERT_DIR", str(tmp_path)) - context = default_ssl_context() - assert isinstance(context, ssl.SSLContext) - assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED