From a38bd564707223a97c5b3b035ed8e78d3e286774 Mon Sep 17 00:00:00 2001 From: Praveena Ganesan Date: Wed, 26 Aug 2026 03:25:38 +0530 Subject: [PATCH 1/2] fix(openai): apply ssl_verify to the TLS client instead of leaking it into extra_body ssl_verify was not listed in all_litellm_params, so the OpenAI param builder swept it into the request body's extra_body and OpenAI-compatible endpoints rejected the request. The OpenAI SDK path also built its httpx client from the global SSL config only, so a per-request CA bundle never reached TLS. Register ssl_verify as a LiteLLM-level param and thread it through _get_openai_client into the sync and async httpx clients, keyed into the client cache so deployments with different CA bundles don't share a client. Fixes #38178 --- litellm/llms/openai/common_utils.py | 10 +- litellm/llms/openai/openai.py | 15 ++- litellm/types/utils.py | 1 + .../llms/openai/test_ssl_verify_not_leaked.py | 120 ++++++++++++++++++ 4 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 1b1ab80e85d..24e354d93cf 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -268,6 +268,7 @@ class BaseOpenAILLM: "max_retries", "organization", "api_base", + "ssl_verify", ) openai_client_fields: Final = ( BaseOpenAILLM.get_openai_client_initialization_param_fields(client_type=client_type) @@ -293,6 +294,7 @@ class BaseOpenAILLM: @staticmethod def _get_async_http_client( shared_session: Optional["ClientSession"] = None, + ssl_verify: bool | str | ssl.SSLContext | None = None, ) -> httpx.AsyncClient | None: if litellm.aclient_session is not None: return litellm.aclient_session @@ -303,7 +305,7 @@ class BaseOpenAILLM: return httpx.AsyncClient(transport=MockOpenAITransport()) # Get unified SSL configuration - ssl_config: Final = get_ssl_configuration() + ssl_config: Final = get_ssl_configuration(ssl_verify=ssl_verify) return httpx.AsyncClient( verify=ssl_config, @@ -316,7 +318,9 @@ class BaseOpenAILLM: ) @staticmethod - def _get_sync_http_client() -> httpx.Client | None: + def _get_sync_http_client( + ssl_verify: bool | str | ssl.SSLContext | None = None, + ) -> httpx.Client | None: if litellm.client_session is not None: return litellm.client_session @@ -326,7 +330,7 @@ class BaseOpenAILLM: return httpx.Client(transport=MockOpenAITransport()) # Get unified SSL configuration - ssl_config: Final = get_ssl_configuration() + ssl_config: Final = get_ssl_configuration(ssl_verify=ssl_verify) return httpx.Client( verify=ssl_config, diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index ee0efb88a38..d3608f5a60e 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -1,3 +1,4 @@ +import ssl import time import types from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping @@ -347,6 +348,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization: str | None = None, client: OpenAI | AsyncOpenAI | None = None, shared_session: Optional["ClientSession"] = None, + ssl_verify: bool | str | ssl.SSLContext | None = None, ) -> OpenAI | AsyncOpenAI | None: client_initialization_params: Final[dict] = locals() if client is None: @@ -364,9 +366,12 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): if isinstance(cached_client, OpenAI) or isinstance(cached_client, AsyncOpenAI): return cached_client http_client: Final[httpx.Client | httpx.AsyncClient | None] = ( - OpenAIChatCompletion._get_async_http_client(shared_session=shared_session) + OpenAIChatCompletion._get_async_http_client( + shared_session=shared_session, + ssl_verify=ssl_verify, + ) if is_async - else OpenAIChatCompletion._get_sync_http_client() + else OpenAIChatCompletion._get_sync_http_client(ssl_verify=ssl_verify) ) if is_async: _new_client: OpenAI | AsyncOpenAI = AsyncOpenAI( @@ -716,6 +721,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, stream_options=stream_options, + ssl_verify=litellm_params.get("ssl_verify"), ) else: if not isinstance(max_retries, int): @@ -729,6 +735,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -870,6 +877,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING @@ -965,6 +973,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=None, headers=None, stream_options: dict | None = None, + ssl_verify: bool | str | ssl.SSLContext | None = None, ): data["stream"] = True data.update(self.get_stream_options(stream_options=stream_options, api_base=api_base)) @@ -978,6 +987,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): max_retries=max_retries, organization=organization, client=client, + ssl_verify=ssl_verify, ) ## LOGGING logging_obj.pre_call( @@ -1050,6 +1060,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): organization=organization, client=client, shared_session=shared_session, + ssl_verify=litellm_params.get("ssl_verify"), ) ## LOGGING logging_obj.pre_call( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 73f46bd2181..3c2d3cb699b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3515,6 +3515,7 @@ all_litellm_params = ( "provider_specific_header", "prompt_version", "api_base", + "ssl_verify", "force_timeout", "logger_fn", "verbose", diff --git a/tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py b/tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py new file mode 100644 index 00000000000..813f049812e --- /dev/null +++ b/tests/test_litellm/llms/openai/test_ssl_verify_not_leaked.py @@ -0,0 +1,120 @@ +""" +Regression test for issue #38178. + +``ssl_verify`` is a LiteLLM-level TLS setting, not a provider body param. It has to +configure the httpx client backing the OpenAI SDK client, and it must never be swept +into ``extra_body``: OpenAI-compatible endpoints reject unknown body fields with a 400. +""" + +from pathlib import Path +from unittest.mock import MagicMock + +import certifi +import pytest + +import litellm +from litellm.llms.custom_httpx.http_handler import get_ssl_configuration +from litellm.llms.openai.openai import OpenAIChatCompletion +from litellm.types.utils import all_litellm_params +from litellm.utils import get_non_default_completion_params + + +@pytest.fixture +def ca_bundle(tmp_path: Path) -> str: + """A real, loadable CA bundle at a path that is not certifi's default.""" + bundle = tmp_path / "corporate-ca.crt" + bundle.write_bytes(Path(certifi.where()).read_bytes()) + return str(bundle) + + +def test_ssl_verify_is_a_known_litellm_param(): + assert "ssl_verify" in all_litellm_params + + +def test_ssl_verify_not_forwarded_as_provider_param(ca_bundle: str): + forwarded = get_non_default_completion_params({"ssl_verify": ca_bundle, "temperature": 0.5}) + assert "ssl_verify" not in forwarded + + +def test_completion_does_not_leak_ssl_verify_into_provider_request_body(ca_bundle: str): + mock_response = MagicMock() + mock_response.model_dump.return_value = { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + mock_raw_response = MagicMock() + mock_raw_response.headers = {} + mock_raw_response.parse.return_value = mock_response + + mock_client = MagicMock() + mock_client.chat.completions.with_raw_response.create.return_value = mock_raw_response + + litellm.completion( + model="openai/gpt-4o-mini", + messages=[{"role": "user", "content": "hi"}], + ssl_verify=ca_bundle, + api_key="sk-test", + client=mock_client, + ) + + create_kwargs = mock_client.chat.completions.with_raw_response.create.call_args.kwargs + assert "ssl_verify" not in create_kwargs + assert "ssl_verify" not in (create_kwargs.get("extra_body") or {}) + + +def test_sync_openai_client_uses_ssl_verify_ca_bundle(ca_bundle: str): + handler = OpenAIChatCompletion() + client = handler._get_openai_client( + is_async=False, + api_key="sk-test", + api_base="https://private.example.com/v1", + max_retries=0, + ssl_verify=ca_bundle, + ) + assert client is not None + pool = client._client._transport._pool + assert pool._ssl_context is get_ssl_configuration(ssl_verify=ca_bundle) + assert pool._ssl_context is not get_ssl_configuration() + + +@pytest.mark.asyncio +async def test_async_openai_client_uses_ssl_verify_ca_bundle(ca_bundle: str): + handler = OpenAIChatCompletion() + client = handler._get_openai_client( + is_async=True, + api_key="sk-test", + api_base="https://private.example.com/v1", + max_retries=0, + ssl_verify=ca_bundle, + ) + assert client is not None + session = client._client._transport._client_factory() + try: + assert session.connector._ssl is get_ssl_configuration(ssl_verify=ca_bundle) + assert session.connector._ssl is not get_ssl_configuration() + finally: + await session.close() + + +def test_openai_client_cache_is_keyed_on_ssl_verify(ca_bundle: str): + handler = OpenAIChatCompletion() + shared_args = { + "is_async": False, + "api_key": "sk-test", + "api_base": "https://private.example.com/v1", + "max_retries": 0, + } + with_ca = handler._get_openai_client(**shared_args, ssl_verify=ca_bundle) + without_ca = handler._get_openai_client(**shared_args) + assert with_ca is not without_ca From fdd27612f4b7273065312e2cb4eea6c3847db4ba Mon Sep 17 00:00:00 2001 From: Praveena Ganesan Date: Wed, 26 Aug 2026 11:13:49 +0530 Subject: [PATCH 2/2] fix(proxy): ban ssl_verify as a request-body param Veria flagged that making ssl_verify functional (previous commit) also made it caller-controlled at the proxy: a client could send ssl_verify=false to disable upstream certificate verification, or a path string that reaches os.path.exists() as a local-file oracle. Add ssl_verify to _BANNED_REQUEST_BODY_PARAMS, same treatment as use_ssl. Legitimate per-deployment CA bundles still work via the existing admin opt-ins (allow_client_side_credentials proxy-wide, or configurable_clientside_auth_params per deployment); only caller- supplied request-body values are rejected. --- litellm/proxy/auth/auth_utils.py | 5 ++ .../proxy/auth/test_auth_utils.py | 69 +++++++++++++++++++ .../auth/test_banned_params_extra_body.py | 1 + 3 files changed, 75 insertions(+) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 9b1a6ba5aa7..494e545c387 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -311,6 +311,11 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( # the request away from the admin's pinned configuration. "nvcf_function_id", "use_ssl", + # TLS trust decision for the outbound provider connection. A caller-supplied + # value downgrades or disables certificate verification on a connection the + # admin pinned, and a string value reaches os.path.exists() as a local-file + # oracle. Deployment-level config only, same as ``use_ssl`` above. + "ssl_verify", # Per-deployment opt-in that hands the whole call to the Rust core. It is a # deployment decision, not a request one: the Rust path uses its own client # rather than the one the deployment configured, and reports no post_call, diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 9301176f3ed..64e06495f4b 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -2447,6 +2447,75 @@ class TestIsRequestBodySafeBlocksRivaUseSsl: ) +class TestIsRequestBodySafeBlocksSslVerify: + """``ssl_verify`` configures TLS trust for the outbound provider connection. + A caller-supplied ``false`` disables certificate verification on a + connection the admin pinned, and a string value reaches + ``os.path.exists()`` as a local-file oracle, so it is rejected as a + request-body param unless the admin opted in proxy-wide or + per-deployment, same as ``use_ssl`` above.""" + + def test_ssl_verify_false_in_request_body_is_rejected(self): + with pytest.raises(ValueError, match="ssl_verify"): + is_request_body_safe( + request_body={ + "model": "openai/gpt-3.5-turbo", + "ssl_verify": False, + }, + general_settings={}, + llm_router=None, + model="openai/gpt-3.5-turbo", + ) + + def test_ssl_verify_path_in_request_body_is_rejected(self): + with pytest.raises(ValueError, match="ssl_verify"): + is_request_body_safe( + request_body={ + "model": "openai/gpt-3.5-turbo", + "ssl_verify": "/etc/passwd", + }, + general_settings={}, + llm_router=None, + model="openai/gpt-3.5-turbo", + ) + + def test_admin_opt_in_proxy_wide_allows_ssl_verify(self): + assert ( + is_request_body_safe( + request_body={ + "model": "openai/gpt-3.5-turbo", + "ssl_verify": "/opt/app/certs/ca.crt", + }, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="openai/gpt-3.5-turbo", + ) + is True + ) + + def test_admin_opt_in_per_deployment_allows_ssl_verify(self, monkeypatch): + from litellm.proxy.auth import auth_utils + + monkeypatch.setattr( + auth_utils, + "_allow_model_level_clientside_configurable_parameters", + lambda model, param, request_body_value, llm_router: param == "ssl_verify", + ) + + assert ( + is_request_body_safe( + request_body={ + "model": "openai/gpt-3.5-turbo", + "ssl_verify": "/opt/app/certs/ca.crt", + }, + general_settings={}, + llm_router=None, + model="openai/gpt-3.5-turbo", + ) + is True + ) + + class TestIsRequestBodySafeBlocksBedrockTags: """``bedrock_tags`` lands as AWS resource tags on Bedrock batch jobs created with the proxy's AWS identity, so a caller-supplied value can diff --git a/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py b/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py index e87b206a40a..8e9ccd4a3b8 100644 --- a/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py +++ b/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py @@ -28,6 +28,7 @@ from litellm.proxy.auth.auth_utils import is_request_body_safe # noqa: E402 "base_url", "vertex_credentials", "azure_ad_token", + "ssl_verify", ], ) def test_banned_param_under_extra_body_is_rejected(banned_param):