diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 95bcd4d7186..203a11e932d 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -1,4 +1,5 @@ from typing import Optional, Tuple +from urllib.parse import urlparse import litellm from litellm.constants import REPLICATE_MODEL_NAME_WITH_ID_LENGTH @@ -8,6 +9,43 @@ from litellm.secret_managers.main import get_secret, get_secret_str from ..types.router import LiteLLM_Params +def _endpoint_matches_api_base(endpoint: str, api_base: str) -> bool: + """ + Match a registered openai-compatible endpoint against a caller-supplied + ``api_base`` using parsed-URL semantics, not unanchored substring search. + + Both inputs may be a bare hostname (``api.perplexity.ai``), host+path + (``api.deepinfra.com/v1/openai``), or a full URL + (``https://api.cerebras.ai/v1``). Hostnames must match exactly + (case-insensitive); if the registered endpoint has a non-trivial path, + the api_base path must start with it on a segment boundary. + + The naive ``endpoint in api_base`` shape lets a caller pass + ``https://attacker.com/api.groq.com/openai/v1`` to coerce the proxy + into reading the server's GROQ_API_KEY from the environment and + forwarding it to the attacker's host as a Bearer credential. + """ + + def _parse(value: str): + # Ensure urlparse sees a scheme so it populates hostname / path. + normalized = value if "://" in value else f"https://{value}" + return urlparse(normalized) + + parsed_endpoint = _parse(endpoint) + parsed_url = _parse(api_base) + + endpoint_host = (parsed_endpoint.hostname or "").lower() + url_host = (parsed_url.hostname or "").lower() + if not endpoint_host or endpoint_host != url_host: + return False + + endpoint_path = parsed_endpoint.path.rstrip("/") + if not endpoint_path: + return True + url_path = parsed_url.path.rstrip("/") + return url_path == endpoint_path or url_path.startswith(endpoint_path + "/") + + def _is_non_openai_azure_model(model: str) -> bool: try: model_name = model.split("/", 1)[1] @@ -210,7 +248,7 @@ def get_llm_provider( # noqa: PLR0915 # check if api base is a known openai compatible endpoint if api_base: for endpoint in litellm.openai_compatible_endpoints: - if endpoint in api_base: + if _endpoint_matches_api_base(endpoint, api_base): if endpoint == "api.perplexity.ai": custom_llm_provider = "perplexity" dynamic_api_key = get_secret_str("PERPLEXITYAI_API_KEY") diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 9527111f5ba..17f87a0cbcb 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -184,6 +184,15 @@ def is_request_body_safe( "aws_web_identity_token", "aws_role_name", "vertex_credentials", + # Endpoint-targeting fields that must not flow through clientside. + # ``aws_bedrock_runtime_endpoint`` redirects Bedrock traffic to an + # arbitrary host; ``langsmith_base_url`` / ``langfuse_host`` are + # callback hostnames that, if attacker-controlled, exfiltrate the + # request payload (incl. message content + admin-configured + # observability keys) to the attacker's server. + "aws_bedrock_runtime_endpoint", + "langsmith_base_url", + "langfuse_host", ] for param in banned_params: diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index 9c1b28976ef..45ade81b2dd 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -46,6 +46,16 @@ def _admin_config_fields_to_clear_on_base_override() -> List[str]: "aws_sts_endpoint", "aws_web_identity_token", "aws_role_name", + # OCI provider — consumed by litellm/llms/oci/* via optional_params + # and not declared on CredentialLiteLLMParams. Without these here, + # an admin's OCI signing key / tenancy / fingerprint would flow + # through to an attacker-redirected upstream. + "oci_signer", + "oci_user", + "oci_fingerprint", + "oci_tenancy", + "oci_key", + "oci_key_file", ] return typed_fields + kwargs_only_fields diff --git a/tests/test_litellm/litellm_core_utils/test_get_llm_provider_endpoint_match.py b/tests/test_litellm/litellm_core_utils/test_get_llm_provider_endpoint_match.py new file mode 100644 index 00000000000..fc5b39a2fd7 --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_get_llm_provider_endpoint_match.py @@ -0,0 +1,138 @@ +""" +Regression tests for the parsed-URL hostname match used to identify a +caller-supplied ``api_base`` as a known openai-compatible provider. + +The previous shape (``if endpoint in api_base:``) used unanchored +substring search, which let a caller pass +``https://attacker.com/api.groq.com/openai/v1`` and have the proxy +return ``GROQ_API_KEY`` as the dynamic credential — exfiltrating the +server's real provider key to an attacker-controlled host on the +outbound request. +""" + +import os +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.litellm_core_utils.get_llm_provider_logic import ( + _endpoint_matches_api_base, + get_llm_provider, +) + + +class TestEndpointMatchesApiBase: + """Direct unit tests on the parsed-URL matcher.""" + + @pytest.mark.parametrize( + "endpoint, api_base", + [ + # Bare hostname endpoint, exact host match. + ("api.perplexity.ai", "https://api.perplexity.ai/v1"), + # Endpoint includes a path; api_base path starts with it. + ("api.groq.com/openai/v1", "https://api.groq.com/openai/v1"), + # Endpoint with full URL scheme. + ("https://api.cerebras.ai/v1", "https://api.cerebras.ai/v1/chat"), + # Trailing-slash on registered endpoint must not break match. + ("https://llm.chutes.ai/v1/", "https://llm.chutes.ai/v1/chat"), + # Case-insensitive on hostname. + ("api.groq.com/openai/v1", "https://API.GROQ.COM/openai/v1"), + ], + ) + def test_legitimate_provider_urls_match(self, endpoint, api_base): + assert _endpoint_matches_api_base(endpoint, api_base) is True + + @pytest.mark.parametrize( + "endpoint, api_base", + [ + # Attacker host, registered endpoint smuggled into path. + ( + "api.groq.com/openai/v1", + "https://attacker.com/api.groq.com/openai/v1", + ), + # Attacker host, registered endpoint smuggled into a path segment. + ( + "api.groq.com/openai/v1", + "https://attacker.com/foo/api.groq.com/openai/v1", + ), + # Lookalike host that contains the registered host as a suffix label. + ( + "api.groq.com/openai/v1", + "https://api.groq.com.attacker.com/openai/v1", + ), + # Lookalike host with the registered host as a prefix. + ( + "api.groq.com/openai/v1", + "https://api.groq.com.evil.example/openai/v1", + ), + # Right host, wrong path — endpoint requires ``/openai/v1`` prefix. + ("api.groq.com/openai/v1", "https://api.groq.com/v1"), + # Path-segment lookalike: ``/openai/v10`` must not match ``/openai/v1``. + ("api.groq.com/openai/v1", "https://api.groq.com/openai/v10"), + # Userinfo / @-injection trick — the ``hostname`` after ``@`` is + # what httpx connects to. + ( + "api.groq.com/openai/v1", + "https://api.groq.com@attacker.com/openai/v1", + ), + ], + ) + def test_attacker_smuggling_does_not_match(self, endpoint, api_base): + assert _endpoint_matches_api_base(endpoint, api_base) is False + + +class TestGetLlmProviderRejectsAttackerSmuggledApiBase: + """ + End-to-end: ``get_llm_provider`` must NOT return the server's stored + secret (e.g. ``GROQ_API_KEY``) for an api_base whose hostname is + attacker-controlled, even when the registered endpoint string appears + elsewhere in the URL. + """ + + def test_attacker_host_does_not_yield_groq_secret(self): + # The function may either fall through (different provider) or + # raise BadRequestError because the model can't be identified. + # The invariant under test is that ``GROQ_API_KEY`` is never + # looked up against an attacker-controlled hostname. + import litellm + + with patch( + "litellm.litellm_core_utils.get_llm_provider_logic.get_secret_str", + return_value="server-real-groq-key", + ) as mocked_secret: + try: + _, _, dynamic_api_key, _ = get_llm_provider( + model="some-model", + api_base="https://attacker.com/api.groq.com/openai/v1", + ) + # If it returned, the dynamic key must not be the secret. + assert dynamic_api_key != "server-real-groq-key" + except litellm.exceptions.BadRequestError: + # Acceptable outcome: provider unidentifiable, no secret + # was returned. + pass + + # Regardless of return / raise, the secret must never have been + # read against this attacker-controlled api_base. + groq_lookups = [ + call + for call in mocked_secret.call_args_list + if call.args and call.args[0] == "GROQ_API_KEY" + ] + assert groq_lookups == [] + + def test_legitimate_groq_api_base_still_resolves(self): + with patch( + "litellm.litellm_core_utils.get_llm_provider_logic.get_secret_str", + return_value="server-real-groq-key", + ): + _, provider, dynamic_api_key, _ = get_llm_provider( + model="some-model", + api_base="https://api.groq.com/openai/v1", + ) + + assert provider == "groq" + assert dynamic_api_key == "server-real-groq-key" diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 1dc704bf10d..a373d797e79 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -17,6 +17,7 @@ from litellm.proxy.auth.auth_utils import ( get_key_model_tpm_limit, get_project_model_rpm_limit, get_project_model_tpm_limit, + is_request_body_safe, ) @@ -876,3 +877,47 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: assert out["organization"] == "org-admin" assert out["api_version"] == "2026-04-01" assert out["api_base"] == "https://admin.upstream/v1" + + +class TestIsRequestBodySafeBlocksEndpointTargetingFields: + """ + ``is_request_body_safe`` rejects request-body fields that retarget the + outbound request to a caller-controlled host. Beyond the original + ``api_base`` / ``base_url``, the same protection must apply to: + + * ``aws_bedrock_runtime_endpoint`` — Bedrock endpoint redirect; an + attacker-controlled value coerces the proxy to authenticate against + their host with the admin's AWS creds. + * ``langsmith_base_url`` — Langsmith callback host; attacker-controlled + values exfiltrate the entire request payload (incl. message content) + via the observability hook. + * ``langfuse_host`` — same exfil vector via the Langfuse hook. + """ + + @pytest.fixture(autouse=True) + def _disable_url_validation(self, monkeypatch): + # The new banned-params entries should be rejected even when + # ``user_url_validation`` is off — the gate isn't the URL guard, + # it's the banned-params list. + import litellm + + monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) + + @pytest.mark.parametrize( + "field", + [ + "aws_bedrock_runtime_endpoint", + "langsmith_base_url", + "langfuse_host", + ], + ) + def test_endpoint_targeting_field_in_request_body_is_rejected(self, field): + with pytest.raises(ValueError) as exc: + is_request_body_safe( + request_body={"model": "gpt-4", field: "https://attacker.example"}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + # The function lists the offending param name in the error. + assert field in str(exc.value)