-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Add opt-in TCPConnector(use_truststore=True) for OS-native trust store integration (#11705) #12702
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 13 commits
1a0d530
86d38c2
3b6b78c
f2940f3
a7aeb9a
2c77a0d
62fed54
c44f998
8de7378
93e50cf
7d1dab0
38f3ddd
a7f7408
42231d6
1619c1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| *aiohttp* now prefers the `truststore <https://truststore.readthedocs.io/>`_ library for TLS certificate verification when the optional dependency is installed, with automatic fallback to the stdlib :mod:`ssl` defaults otherwise. This resolves common ``CERTIFICATE_VERIFY_FAILED`` errors for users behind enterprise TLS-intercepting proxies whose root CA lives in the macOS Keychain or Windows certificate stores. Install the optional dependency with ``pip install aiohttp[truststore]`` -- by :user:`Krishnachaitanyakc`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -101,6 +101,14 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]: | |
| bb.functions[func].can_block_in( | ||
| "aiohttp/web_urldispatcher.py", "add_static" | ||
| ) | ||
| # truststore's _configure_context probes well-known CA file/dir | ||
| # locations on every wrap_socket call; on Linux this is a no-op | ||
| # versus stdlib but blockbuster flags the os.path.is* probes. | ||
| for func, fn_names in ( | ||
| ("os.stat", ("_configure_context", "_capath_contains_certs")), | ||
| ("os.listdir", ("_capath_contains_certs",)), | ||
| ): | ||
| bb.functions[func].can_block_in("truststore/_openssl.py", fn_names) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like blockbuster is hitting in the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a limit of Python's ssl module, or something truststore could improve in some way?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this is solvable, as the blocking calls are initiated from asyncio. An alternative approach that would still give us some of the benefits, would be to read the OS store into an SSLContext at import time, much the same as our existing code. That would get us the OS cert store, thus improving compatibility with TLS servers, but would lose some benefits of truststore (like CRL checking). |
||
| # Note: coverage.py uses locking internally which can cause false positives | ||
| # in blockbuster when it instruments code. This is particularly problematic | ||
| # on Windows where it can lead to flaky test failures. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| """Tests for automatic ``truststore`` preference on :class:`TCPConnector`. | ||
|
|
||
| The ``truststore`` library delegates TLS certificate verification to the | ||
| operating system's native trust store. When ``truststore`` is importable, | ||
| ``aiohttp.connector`` automatically uses ``truststore.SSLContext`` for its | ||
| default verified context; otherwise it falls back to the stdlib | ||
| ``ssl.create_default_context()``. This module covers both branches. | ||
|
|
||
| These tests intentionally do not perform live TLS handshakes — they exercise | ||
| the SSL-context construction and dispatch logic only. | ||
| """ | ||
|
|
||
| import ssl | ||
| from unittest import mock | ||
|
|
||
| import pytest | ||
|
|
||
| from aiohttp import TCPConnector, connector as connector_module | ||
| from aiohttp.client_reqrep import Fingerprint | ||
|
|
||
|
|
||
| def _has_truststore() -> bool: | ||
| try: | ||
| import truststore # type: ignore[import-not-found,unused-ignore] # noqa: F401 | ||
| except ImportError: | ||
| return False | ||
| return True | ||
|
|
||
|
|
||
| def test_has_truststore_matches_importability() -> None: | ||
| """``HAS_TRUSTSTORE`` reflects whether the library can be imported.""" | ||
| assert connector_module.HAS_TRUSTSTORE is _has_truststore() | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not _has_truststore(), reason="truststore not installed") | ||
| def test_make_ssl_context_uses_truststore_when_available() -> None: | ||
| """Verified context is a truststore.SSLContext when the lib is installed.""" | ||
| import truststore # type: ignore[import-not-found,unused-ignore] | ||
|
|
||
| ctx = connector_module._make_ssl_context(True) | ||
| assert isinstance(ctx, truststore.SSLContext) | ||
|
|
||
|
|
||
| def test_make_ssl_context_falls_back_to_stdlib_when_truststore_absent() -> None: | ||
| """Verified context is a plain ssl.SSLContext when truststore is missing. | ||
|
|
||
| Uses ``type() is`` rather than ``isinstance``: ``truststore.SSLContext`` | ||
| subclasses ``ssl.SSLContext``, so ``isinstance`` would pass in both | ||
| branches and silently hide a regression here. | ||
| """ | ||
| with mock.patch.object(connector_module, "HAS_TRUSTSTORE", False): | ||
| ctx = connector_module._make_ssl_context(True) | ||
| assert type(ctx) is ssl.SSLContext | ||
|
|
||
|
|
||
| def test_make_ssl_context_unverified_path_does_not_touch_truststore() -> None: | ||
| """Unverified context never uses truststore, regardless of HAS_TRUSTSTORE.""" | ||
| with mock.patch.object(connector_module, "HAS_TRUSTSTORE", True): | ||
| ctx = connector_module._make_ssl_context(False) | ||
| assert type(ctx) is ssl.SSLContext | ||
| assert ctx.verify_mode == ssl.CERT_NONE | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not _has_truststore(), reason="truststore not installed") | ||
| def test_make_ssl_context_verified_with_truststore_sets_alpn() -> None: | ||
| """``set_alpn_protocols`` works on a truststore-backed context.""" | ||
| ctx = connector_module._make_ssl_context(True) | ||
| assert isinstance(ctx, ssl.SSLContext) | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not _has_truststore(), reason="truststore not installed") | ||
| async def test_get_ssl_context_returns_module_level_verified() -> None: | ||
| """Default verified request returns the module-level ``_SSL_CONTEXT_VERIFIED``.""" | ||
| import truststore # type: ignore[import-not-found,unused-ignore] | ||
|
|
||
| conn = TCPConnector() | ||
| try: | ||
| req = mock.Mock() | ||
| req.is_ssl.return_value = True | ||
| req.ssl = True | ||
| returned = conn._get_ssl_context(req) | ||
| assert returned is connector_module._SSL_CONTEXT_VERIFIED | ||
| assert isinstance(returned, truststore.SSLContext) | ||
| finally: | ||
| await conn.close() | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not _has_truststore(), reason="truststore not installed") | ||
| async def test_explicit_ssl_context_overrides_default() -> None: | ||
| """An explicit ``ssl=<SSLContext>`` argument wins over the default.""" | ||
| explicit_ctx = ssl.create_default_context() | ||
| conn = TCPConnector(ssl=explicit_ctx) | ||
| try: | ||
| req = mock.Mock() | ||
| req.is_ssl.return_value = True | ||
| req.ssl = True | ||
| assert conn._get_ssl_context(req) is explicit_ctx | ||
| finally: | ||
| await conn.close() | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not _has_truststore(), reason="truststore not installed") | ||
| async def test_fingerprint_uses_unverified_context_even_with_truststore() -> None: | ||
| """A ``Fingerprint`` replaces CA verification; truststore must not apply.""" | ||
| fingerprint = Fingerprint(b"\x00" * 32) | ||
| conn = TCPConnector(ssl=fingerprint) | ||
| try: | ||
| req = mock.Mock() | ||
| req.is_ssl.return_value = True | ||
| req.ssl = True | ||
| returned = conn._get_ssl_context(req) | ||
| assert returned is connector_module._SSL_CONTEXT_UNVERIFIED | ||
| finally: | ||
| await conn.close() |
Uh oh!
There was an error while loading. Please reload this page.