diff --git a/mock_tests/test_grpc_config.py b/mock_tests/test_grpc_config.py index e0626118c..4ac24626f 100644 --- a/mock_tests/test_grpc_config.py +++ b/mock_tests/test_grpc_config.py @@ -1,6 +1,7 @@ from typing import Any, List, Tuple from unittest.mock import MagicMock +import certifi import grpc import pytest @@ -47,18 +48,36 @@ def test_grpc_config_channel_options() -> None: def test_secure_channel_default_credentials( - secure_params: ConnectionParams, mock_grpc: MagicMock, mock_ssl_creds: MagicMock + secure_params: ConnectionParams, + mock_grpc: MagicMock, + mock_ssl_creds: MagicMock, + monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.delenv("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", raising=False) mock_channel = MagicMock() mock_grpc.secure_channel.return_value = mock_channel result = secure_params._grpc_channel(proxies={}, grpc_msg_size=None, is_async=False) - mock_ssl_creds.assert_called_once_with() - mock_grpc.secure_channel.assert_called_once() + with open(certifi.where(), "rb") as f: + mock_ssl_creds.assert_called_once_with(root_certificates=f.read()) + assert mock_grpc.secure_channel.call_args.kwargs["credentials"] is mock_ssl_creds.return_value assert result is mock_channel +def test_secure_channel_roots_file_env_overrides_certifi( + secure_params: ConnectionParams, + mock_grpc: MagicMock, + mock_ssl_creds: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", "/etc/ssl/corp-roots.pem") + + secure_params._grpc_channel(proxies={}, grpc_msg_size=None, is_async=False) + + mock_ssl_creds.assert_called_once_with() + + def test_insecure_channel_no_config( insecure_params: ConnectionParams, mock_grpc: MagicMock ) -> None: diff --git a/setup.cfg b/setup.cfg index 958118ed2..b964b7ef3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,9 @@ platforms = any include_package_data = True install_requires = httpx>=0.26.0,<0.29.0 + # CA bundle for both REST (via httpx) and gRPC. 2026.10.19 drops the emSign ECC + # Root CA - G3, whose intermediates' private keys were compromised in October 2026. + certifi>=2026.10.19 validators>=0.34.0,<1.0.0 authlib>=1.7.1,<2.0.0 pydantic>=2.12.0,<3.0.0 diff --git a/weaviate/config.py b/weaviate/config.py index d4ce8533c..30969d83e 100644 --- a/weaviate/config.py +++ b/weaviate/config.py @@ -85,6 +85,7 @@ class GrpcConfig(BaseModel): To provide your own `channel_options`, supply a list of tuples where each tuple contains the name of the gRPC channel option and its corresponding value. [Reference](https://grpc.github.io/grpc/python/glossary.html#term-channel_arguments) + Without `credentials`, secure channels trust certifi's CA bundle, or the roots file named by the `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` environment variable if it is set. To provide your own `credentials`, use the `ssl_channel_credentials()` function from the `grpc` library to build a `ChannelCredentials` object. [Reference](https://grpc.github.io/grpc/python/grpc.html#grpc.ssl_channel_credentials) diff --git a/weaviate/connect/base.py b/weaviate/connect/base.py index 5ac4689d8..8568985a7 100644 --- a/weaviate/connect/base.py +++ b/weaviate/connect/base.py @@ -2,6 +2,7 @@ from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, TypeVar, Union, cast from urllib.parse import urlparse +import certifi import grpc # type: ignore from grpc import Channel as SyncChannel from grpc import ssl_channel_credentials @@ -30,6 +31,18 @@ def _grpc_web_shim_active() -> bool: return getattr(grpc, "__weaviate_client_web_shim__", False) is True +def _default_grpc_credentials() -> grpc.ChannelCredentials: + """SSL credentials trusting certifi's CA bundle, the same one httpx uses for REST. + + grpcio's bundled roots only change with a grpcio release, so a CA distrust reaches + certifi first. A roots file set via GRPC_DEFAULT_SSL_ROOTS_FILE_PATH still wins. + """ + if os.environ.get("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"): + return ssl_channel_credentials() + with open(certifi.where(), "rb") as f: + return ssl_channel_credentials(root_certificates=f.read()) + + class ProtocolParams(BaseModel): host: str port: int @@ -201,7 +214,7 @@ def _grpc_channel( if grpc_config is not None and grpc_config.credentials is not None: creds = grpc_config.credentials else: - creds = ssl_channel_credentials() + creds = _default_grpc_credentials() return mod.secure_channel( target=self._grpc_target, credentials=creds,