]> git.ipfire.org Git - thirdparty/httpx.git/commitdiff
Add support for SSLKEYLOGFILE (#301)
authorTomás Farías <tomasfariassantana@gmail.com>
Sun, 1 Sep 2019 13:01:11 +0000 (10:01 -0300)
committerSeth Michael Larson <sethmichaellarson@gmail.com>
Sun, 1 Sep 2019 13:01:11 +0000 (08:01 -0500)
* Skip test if OpenSSL version is lower than 1.1.1

* Use bionic dist for Python 3.8 job

* Pass trust_env to SSLConfig use monkeypatch in testing

* Don't raise KeyError if SSLKEYLOGFILE is not set

* Move trust_env after verify and cert

.travis.yml
docs/environment_variables.md
httpx/client.py
httpx/config.py
httpx/dispatch/connection.py
httpx/dispatch/connection_pool.py
tests/test_config.py

index 07207f99d152ec533d4f7e991a57258940e56ef5..2a8da531440fa348ceb5ac706515d53e8caf8574 100644 (file)
@@ -20,6 +20,7 @@ matrix:
       env: NOX_SESSION=test-3.7
     - python: 3.8-dev
       env: NOX_SESSION=test-3.8
+      dist: bionic # Required to get OpenSSL 1.1.1+
 
 install:
   - pip install --upgrade nox
index be1b5810b723d2e8d903e73974932e6d9cd50484..d8f3960d49f4a92ddbc45003e5f326d26dce1468 100644 (file)
@@ -44,3 +44,38 @@ user@host:~$ HTTPX_DEBUG=1 python test_script.py
 20:54:17.742 - httpx.dispatch.http2 - receive_event stream_id=0 event=<PingReceived ping_data:0000000000000000>
 20:54:17.743 - httpx.dispatch.connection_pool - release_connection connection=HTTPConnection(origin=Origin(scheme='https' host='www.google.com' port=443))
 ```
+
+`SSLKEYLOGFILE`
+-----------
+
+Valid values: a filename
+
+If this environment variable is set, TLS keys will be appended to the specified file, creating it if it doesn't exist, whenever key material is generated or received. The keylog file is designed for debugging purposes only.
+
+Support for `SSLKEYLOGFILE` requires Python 3.8 and OpenSSL 1.1.1 or newer.
+
+Example:
+
+```python
+# test_script.py
+
+import httpx
+client = httpx.Client()
+client.get("https://google.com")
+```
+
+```console
+SSLKEYLOGFILE=test.log python test_script.py
+cat test.log
+# TLS secrets log file, generated by OpenSSL / Python
+SERVER_HANDSHAKE_TRAFFIC_SECRET XXXX
+EXPORTER_SECRET XXXX
+SERVER_TRAFFIC_SECRET_0 XXXX
+CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX
+CLIENT_TRAFFIC_SECRET_0 XXXX
+SERVER_HANDSHAKE_TRAFFIC_SECRET XXXX
+EXPORTER_SECRET XXXX
+SERVER_TRAFFIC_SECRET_0 XXXX
+CLIENT_HANDSHAKE_TRAFFIC_SECRET XXXX
+CLIENT_TRAFFIC_SECRET_0 XXXX
+```
index 44a092190ee2005dcec612ce9c9e378c46d84a42..3c135e29cafb15ad21a88e7eef8efe8267b1fbd7 100644 (file)
@@ -82,6 +82,8 @@ class BaseClient:
             else:
                 dispatch = ASGIDispatch(app=app)
 
+        self.trust_env = True if trust_env is None else trust_env
+
         if dispatch is None:
             async_dispatch: AsyncDispatcher = ConnectionPool(
                 verify=verify,
@@ -90,6 +92,7 @@ class BaseClient:
                 http_versions=http_versions,
                 pool_limits=pool_limits,
                 backend=backend,
+                trust_env=self.trust_env,
             )
         elif isinstance(dispatch, Dispatcher):
             async_dispatch = ThreadedDispatcher(dispatch, backend)
@@ -107,7 +110,6 @@ class BaseClient:
         self.max_redirects = max_redirects
         self.dispatch = async_dispatch
         self.concurrency_backend = backend
-        self.trust_env = True if trust_env is None else trust_env
 
     @property
     def headers(self) -> Headers:
index 4082a9f7aacd320fd78fd767012bb5cad8d50457..99e9240f49eea19224b16476409144621204e39b 100644 (file)
@@ -1,3 +1,4 @@
+import os
 import ssl
 import typing
 from pathlib import Path
@@ -43,7 +44,13 @@ class SSLConfig:
     SSL Configuration.
     """
 
-    def __init__(self, *, cert: CertTypes = None, verify: VerifyTypes = True):
+    def __init__(
+        self,
+        *,
+        cert: CertTypes = None,
+        verify: VerifyTypes = True,
+        trust_env: bool = None,
+    ):
         self.cert = cert
 
         # Allow passing in our own SSLContext object that's pre-configured.
@@ -56,6 +63,7 @@ class SSLConfig:
 
         self.ssl_context: typing.Optional[ssl.SSLContext] = ssl_context
         self.verify: typing.Union[str, bool] = verify
+        self.trust_env = trust_env
 
     def __eq__(self, other: typing.Any) -> bool:
         return (
@@ -166,6 +174,11 @@ class SSLConfig:
         if ssl.HAS_NPN:  # pragma: no cover
             context.set_npn_protocols(http_versions.alpn_identifiers)
 
+        if hasattr(context, "keylog_filename"):
+            keylogfile = os.environ.get("SSLKEYLOGFILE")
+            if keylogfile and self.trust_env:
+                context.keylog_filename = keylogfile  # type: ignore
+
         return context
 
     def _load_client_certs(self, ssl_context: ssl.SSLContext) -> None:
index b843c2e53cdd31fbf3af08ed2f7a52851f13b2a1..529d373aeea27ac3464cb6e0ce49d6102221320b 100644 (file)
@@ -33,13 +33,14 @@ class HTTPConnection(AsyncDispatcher):
         origin: typing.Union[str, Origin],
         verify: VerifyTypes = True,
         cert: CertTypes = None,
+        trust_env: bool = None,
         timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
         http_versions: HTTPVersionTypes = None,
         backend: ConcurrencyBackend = None,
         release_func: typing.Optional[ReleaseCallback] = None,
     ):
         self.origin = Origin(origin) if isinstance(origin, str) else origin
-        self.ssl = SSLConfig(cert=cert, verify=verify)
+        self.ssl = SSLConfig(cert=cert, verify=verify, trust_env=trust_env)
         self.timeout = TimeoutConfig(timeout)
         self.http_versions = HTTPVersionConfig(http_versions)
         self.backend = AsyncioBackend() if backend is None else backend
index 3699209197ae4bd38e80ee13ca28d4c58443c0b0..befa88cb400713ae49690514124e21071feb09a6 100644 (file)
@@ -84,6 +84,7 @@ class ConnectionPool(AsyncDispatcher):
         *,
         verify: VerifyTypes = True,
         cert: CertTypes = None,
+        trust_env: bool = None,
         timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG,
         pool_limits: PoolLimits = DEFAULT_POOL_LIMITS,
         http_versions: HTTPVersionTypes = None,
@@ -95,6 +96,7 @@ class ConnectionPool(AsyncDispatcher):
         self.pool_limits = pool_limits
         self.http_versions = http_versions
         self.is_closed = False
+        self.trust_env = trust_env
 
         self.keepalive_connections = ConnectionStore()
         self.active_connections = ConnectionStore()
@@ -145,6 +147,7 @@ class ConnectionPool(AsyncDispatcher):
                 http_versions=self.http_versions,
                 backend=self.backend,
                 release_func=self.release_connection,
+                trust_env=self.trust_env,
             )
             logger.debug(f"new_connection connection={connection!r}")
         else:
index 0df1029b86fa3a9e8b151bd512672ff22d581f34..fefc06d3accaaa325ea0117b06e41fe5726a50bf 100644 (file)
@@ -1,4 +1,5 @@
 import ssl
+import sys
 
 import pytest
 
@@ -171,3 +172,33 @@ def test_timeout_from_tuple():
 def test_timeout_from_config_instance():
     timeout = httpx.TimeoutConfig(timeout=5.0)
     assert httpx.TimeoutConfig(timeout) == httpx.TimeoutConfig(timeout=5.0)
+
+
+@pytest.mark.skipif(
+    not hasattr(ssl.SSLContext, "keylog_filename"),
+    reason="requires OpenSSL 1.1.1 or higher",
+)
+@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python3.8 or higher")
+def test_ssl_config_support_for_keylog_file(tmpdir, monkeypatch):
+    with monkeypatch.context() as m:
+        m.delenv("SSLKEYLOGFILE", raising=False)
+
+        ssl_config = httpx.SSLConfig(trust_env=True)
+        ssl_config.load_ssl_context()
+
+        assert ssl_config.ssl_context.keylog_filename is None
+
+    filename = str(tmpdir.join("test.log"))
+
+    with monkeypatch.context() as m:
+        m.setenv("SSLKEYLOGFILE", filename)
+
+        ssl_config = httpx.SSLConfig(trust_env=True)
+        ssl_config.load_ssl_context()
+
+        assert ssl_config.ssl_context.keylog_filename == filename
+
+        ssl_config = httpx.SSLConfig(trust_env=False)
+        ssl_config.load_ssl_context()
+
+        assert ssl_config.ssl_context.keylog_filename is None