Repository: encode/httpx

Issue:
Add support for SSL_CERT_FILE, REQUESTS_CA_BUNDLE, and CURL_CA_BUNDLE
Part of #33 these three environment variables all have the same behavior, to specify where a CA bundle is if none are specified by default and `trust_env` is True.

We should check the three environment variables in the order above, as SSL_CERT_FILE is a PEP and a standard whereas the other are products of other projects.

If multiple are set and that file doesn't exist we should skip it and try the next environment variable.

We'll also have to document these environment variables within `docs/environment.md`.


Discussion:
Coming back to this again - do we really want all three of those environment variables, or should we be trying to keep things a bit more narrow?

I know I referenced REQUESTS_CA_BUNDLE in #33 in the first place, but it's not so clear to me now if we should be trying to keep it slimmer.
I suppose REQUEST_CA_BUNDLE is a Requests-specific environment variable, right? The main argument to keeping it is compatibility with Requests, but now that we’ve got SSL_CERT_FILE as a PEP standard it might be one of those changes we could document along with the more modern alternative we suggest.
Yeah, I think our starting point should be the PEP - https://www.python.org/dev/peps/pep-0476/#trust-database - Ie. let's support `SSL_CERT_FILE` and `SSL_CERT_DIR`.

We could always walk back from that point to add support requests and curl style env vars in addition, but I don't think it's obvious if we want to do that or not.
Definitely in support of only `SSL_CERT_FILE` and `SSL_CERT_DIR`.

Resolving patch:
diff --git a/httpx/config.py b/httpx/config.py
--- a/httpx/config.py
+++ b/httpx/config.py
@@ -6,6 +6,7 @@
 import certifi
 
 from .__version__ import __version__
+from .utils import get_ca_bundle_from_env
 
 CertTypes = typing.Union[str, typing.Tuple[str, str], typing.Tuple[str, str, str]]
 VerifyTypes = typing.Union[str, bool, ssl.SSLContext]
@@ -117,6 +118,11 @@ def load_ssl_context_verify(
         """
         Return an SSL context for verified connections.
         """
+        if self.trust_env and self.verify is True:
+            ca_bundle = get_ca_bundle_from_env()
+            if ca_bundle is not None:
+                self.verify = ca_bundle  # type: ignore
+
         if isinstance(self.verify, bool):
             ca_bundle_path = DEFAULT_CA_BUNDLE_PATH
         elif Path(self.verify).exists():
diff --git a/httpx/utils.py b/httpx/utils.py
--- a/httpx/utils.py
+++ b/httpx/utils.py
@@ -111,6 +111,18 @@ def get_netrc_login(host: str) -> typing.Optional[typing.Tuple[str, str, str]]:
     return netrc_info.authenticators(host)  # type: ignore
 
 
+def get_ca_bundle_from_env() -> typing.Optional[str]:
+    if "SSL_CERT_FILE" in os.environ:
+        ssl_file = Path(os.environ["SSL_CERT_FILE"])
+        if ssl_file.is_file():
+            return str(ssl_file)
+    if "SSL_CERT_DIR" in os.environ:
+        ssl_path = Path(os.environ["SSL_CERT_DIR"])
+        if ssl_path.is_dir():
+            return str(ssl_path)
+    return None
+
+
 def parse_header_links(value: str) -> typing.List[typing.Dict[str, str]]:
     """
     Returns a list of parsed link headers, for more info see:


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,5 +1,8 @@
+import os
+import socket
 import ssl
 import sys
+from pathlib import Path
 
 import pytest
 
@@ -26,6 +29,30 @@ def test_load_ssl_config_verify_existing_file():
     assert context.check_hostname is True
 
 
+@pytest.mark.parametrize("config", ("SSL_CERT_FILE", "SSL_CERT_DIR"))
+def test_load_ssl_config_verify_env_file(https_server, ca_cert_pem_file, config):
+    os.environ[config] = (
+        ca_cert_pem_file
+        if config.endswith("_FILE")
+        else str(Path(ca_cert_pem_file).parent)
+    )
+    ssl_config = httpx.SSLConfig(trust_env=True)
+    context = ssl_config.load_ssl_context()
+    assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
+    assert context.check_hostname is True
+    assert ssl_config.verify == os.environ[config]
+
+    # Skipping 'SSL_CERT_DIR' functional test for now because
+    # we're unable to get the certificate within the directory to
+    # load into the SSLContext. :(
+    if config == "SSL_CERT_FILE":
+        host = https_server.url.host
+        port = https_server.url.port
+        conn = socket.create_connection((host, port))
+        context.wrap_socket(conn, server_hostname=host)
+        assert len(context.get_ca_certs()) == 1
+
+
 def test_load_ssl_config_verify_directory():
     path = httpx.config.DEFAULT_CA_BUNDLE_PATH.parent
     ssl_config = httpx.SSLConfig(verify=path)
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -8,6 +8,7 @@
 from httpx import utils
 from httpx.utils import (
     ElapsedTimer,
+    get_ca_bundle_from_env,
     get_environment_proxies,
     get_netrc_login,
     guess_json_utf,
@@ -120,6 +121,42 @@ async def test_httpx_debug_enabled_stderr_logging(server, capsys, httpx_debug):
     logging.getLogger("httpx").handlers = []
 
 
+def test_get_ssl_cert_file():
+    # Two environments is not set.
+    assert get_ca_bundle_from_env() is None
+
+    os.environ["SSL_CERT_DIR"] = "tests/"
+    # SSL_CERT_DIR is correctly set, SSL_CERT_FILE is not set.
+    assert get_ca_bundle_from_env() == "tests"
+
+    del os.environ["SSL_CERT_DIR"]
+    os.environ["SSL_CERT_FILE"] = "tests/test_utils.py"
+    # SSL_CERT_FILE is correctly set, SSL_CERT_DIR is not set.
+    assert get_ca_bundle_from_env() == "tests/test_utils.py"
+
+    os.environ["SSL_CERT_FILE"] = "wrongfile"
+    # SSL_CERT_FILE is set with wrong file,  SSL_CERT_DIR is not set.
+    assert get_ca_bundle_from_env() is None
+
+    del os.environ["SSL_CERT_FILE"]
+    os.environ["SSL_CERT_DIR"] = "wrongpath"
+    # SSL_CERT_DIR is set with wrong path,  SSL_CERT_FILE is not set.
+    assert get_ca_bundle_from_env() is None
+
+    os.environ["SSL_CERT_DIR"] = "tests/"
+    os.environ["SSL_CERT_FILE"] = "tests/test_utils.py"
+    # Two environments is correctly set.
+    assert get_ca_bundle_from_env() == "tests/test_utils.py"
+
+    os.environ["SSL_CERT_FILE"] = "wrongfile"
+    # Two environments is set but SSL_CERT_FILE is not a file.
+    assert get_ca_bundle_from_env() == "tests"
+
+    os.environ["SSL_CERT_DIR"] = "wrongpath"
+    # Two environments is set but both are not correct.
+    assert get_ca_bundle_from_env() is None
+
+
 @pytest.mark.asyncio
 async def test_elapsed_timer():
     with ElapsedTimer() as timer: