Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions litellm/proxy/auth/model_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@

import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.proxy._types import SpecialModelNames, UserAPIKeyAuth
from litellm.router import Router
from litellm.router_utils.fallback_event_handlers import get_fallback_model_group
from litellm.types.router import LiteLLM_Params
from litellm.types.router import CredentialLiteLLMParams, LiteLLM_Params
from litellm.utils import get_valid_models


_CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields)


def _check_wildcard_routing(model: str) -> bool:
"""
Returns True if a model is a provider wildcard.
Expand Down Expand Up @@ -178,6 +182,7 @@ def get_complete_model_list(
model_access_groups: Dict[str, List[str]] = {},
include_model_access_groups: Optional[bool] = False,
only_model_access_groups: Optional[bool] = False,
team_id: Optional[str] = None,
) -> List[str]:
"""Logic for returning complete model list for a given key + team pair"""

Expand Down Expand Up @@ -222,13 +227,37 @@ def append_unique(models):
unique_models=unique_models,
return_wildcard_routes=return_wildcard_routes,
llm_router=llm_router,
team_id=team_id,
)

complete_model_list = unique_models + all_wildcard_models

return complete_model_list


def _hydrate_litellm_credential_name(
litellm_params: Optional[LiteLLM_Params],
) -> Optional[LiteLLM_Params]:
if litellm_params is None or litellm_params.litellm_credential_name is None:
return litellm_params

credential_values = CredentialAccessor.get_credential_values(
litellm_params.litellm_credential_name
)
if not credential_values:
return litellm_params

litellm_params = litellm_params.model_copy()
for key, value in credential_values.items():
if (
key in _CREDENTIAL_LITELLM_PARAM_FIELDS
and getattr(litellm_params, key, None) is None
):
setattr(litellm_params, key, value)
litellm_params.litellm_credential_name = None
return litellm_params
Comment on lines +250 to +258

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Blind setattr could silently overwrite any LiteLLM_Params field. If a stored credential ever includes a "model" key (or other core field like "api_version"), it would quietly shadow the deployment's configured model name during wildcard discovery, producing an incorrect provider list with no error or warning. Since LiteLLM_Params uses extra="allow", unexpected keys would also persist as phantom attributes.

Suggested change
litellm_params = litellm_params.model_copy()
for key, value in credential_values.items():
setattr(litellm_params, key, value)
litellm_params.litellm_credential_name = None
return litellm_params
_CREDENTIAL_FIELDS = {"api_key", "api_base", "api_version", "aws_access_key_id", "aws_secret_access_key", "aws_region_name"}
litellm_params = litellm_params.model_copy()
for key, value in credential_values.items():
if key in _CREDENTIAL_FIELDS or key in LiteLLM_Params.model_fields:
setattr(litellm_params, key, value)
litellm_params.litellm_credential_name = None
return litellm_params



def get_known_models_from_wildcard(
wildcard_model: str, litellm_params: Optional[LiteLLM_Params] = None
) -> List[str]:
Expand All @@ -247,7 +276,7 @@ def get_known_models_from_wildcard(
else:
provider = wildcard_provider_prefix

# get all known provider models
litellm_params = _hydrate_litellm_credential_name(litellm_params)

wildcard_models = get_provider_models(
provider=provider, litellm_params=litellm_params
Expand Down Expand Up @@ -285,6 +314,7 @@ def _get_wildcard_models(
unique_models: List[str],
return_wildcard_routes: Optional[bool] = False,
llm_router: Optional[Router] = None,
team_id: Optional[str] = None,
) -> List[str]:
models_to_remove = set()
all_wildcard_models = []
Expand All @@ -297,7 +327,9 @@ def _get_wildcard_models(

## get litellm params from model
if llm_router is not None:
model_list = llm_router.get_model_list(model_name=model)
model_list = llm_router.get_model_list(
model_name=model, team_id=team_id
)
if model_list:
for router_model in model_list:
wildcard_models = get_known_models_from_wildcard(
Expand Down
3 changes: 3 additions & 0 deletions litellm/proxy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6068,6 +6068,8 @@ async def get_available_models_for_user(
include_model_access_groups=include_model_access_groups,
)

effective_team_id = team_id or user_api_key_dict.team_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Unvalidated team_id used for wildcard discovery

A caller can pass ?team_id=<other-team> to /v1/models in configurations where the validation block above is skipped, such as no DB/cache-backed team lookup, and this value is then sent to Router.get_model_list for wildcard expansion. That makes discovery use the other team's team-scoped deployment and hydrated credential; only propagate the requested team_id after membership validation succeeds, otherwise use user_api_key_dict.team_id or reject the request.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tl;dr - technically true, though actual severity is vastly overstated:

AI analysis below

The finding is technically valid but practically very low severity. Here's the breakdown:

What's technically true

In [utils.py:6051](litellm/proxy/utils.py:6051), the validation guard:

if team_id and prisma_client and proxy_logging_obj and user_api_key_cache:

skips membership validation when prisma_client is None. When skipped, line 6071 propagates the raw query parameter:

effective_team_id = team_id or user_api_key_dict.team_id

This unvalidated team_id reaches [router.py:9373](litellm/router.py:9373):

if team_id is not None and team_id in self.team_pattern_routers:
    potential_team_only_wildcard_models = self.team_pattern_routers[team_id].route(...)

...which could return another team's wildcard deployments.

Why it's practically not exploitable

  1. Self-contradictory configuration: prisma_client=None means no database. But team-scoped wildcard models are created via management endpoints (model_management_endpoints.py) that require a database. The only way to populate team_pattern_routers without a DB is via YAML config — a highly unusual setup.

  2. No credential leakage in response: The /v1/models response returns only model name strings (List[str]). Credentials are hydrated server-side inside get_known_models_from_wildcard to resolve provider model lists, but the actual API keys/secrets never appear in the HTTP response.

  3. When validation DOES run (the normal production case where prisma_client is set): validate_membership at [utils.py:6059](litellm/proxy/utils.py:6059) raises an exception if the user isn't a team member. If they ARE a member, accessing that team's models is correct behavior.

  4. Authentication still required: The endpoint is behind user_api_key_auth, and team IDs are UUIDs (not guessable).

The one real (minor) side-effect

If someone did run this contrived configuration, the other team's hydrated credential would be used server-side in get_valid_models() to query the provider for model lists — a minor quota/rate-limit impact on the other team's API key. But no secrets are returned to the caller.

Verdict: Not a real vulnerability in any realistic deployment. You can safely dismiss or mark as "won't fix" with a note that the guard already works when a database is present, and team-scoped wildcards inherently require one.


# Get complete model list
all_models = get_complete_model_list(
key_models=key_models,
Expand All @@ -6080,6 +6082,7 @@ async def get_available_models_for_user(
model_access_groups=model_access_groups,
include_model_access_groups=include_model_access_groups,
only_model_access_groups=only_model_access_groups,
team_id=effective_team_id,
)

return all_models
Expand Down
238 changes: 238 additions & 0 deletions tests/test_litellm/proxy/auth/test_model_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,241 @@ def test_get_complete_model_list_byok_wildcard_expansion():
assert len(result) > 0
assert all(m.startswith("openai/") for m in result)
assert "openai/*" not in result


def test_get_complete_model_list_expands_team_scoped_wildcard_with_stored_credential(
monkeypatch,
):
"""
Team-scoped BYOK wildcard deployments are stored under an internal model_name,
with the public wildcard name in model_info.team_public_model_name.
"""
import litellm
from litellm import Router
from litellm.proxy.auth import model_checks
from litellm.proxy.auth.model_checks import get_complete_model_list
from litellm.types.utils import CredentialItem

monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai-credential",
credential_info={"provider": "openai"},
credential_values={
"api_key": "stored-openai-key",
"api_base": "https://example.openai.test/v1",
},
)
],
)

captured_params = {}

def fake_get_provider_models(provider, litellm_params=None):
captured_params["provider"] = provider
captured_params["api_key"] = litellm_params.api_key
captured_params["api_base"] = litellm_params.api_base
captured_params["credential_name"] = litellm_params.litellm_credential_name
return ["gpt-4o"]

monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models)

router = Router(
model_list=[
{
"model_name": "model_name_team-1_generated",
"litellm_params": {
"model": "openai/*",
"custom_llm_provider": "openai",
"litellm_credential_name": "openai-credential",
},
"model_info": {
"team_id": "team-1",
"team_public_model_name": "openai/*",
},
}
]
)

result = get_complete_model_list(
key_models=[],
team_models=["openai/*"],
proxy_model_list=[],
user_model=None,
infer_model_from_keys=False,
llm_router=router,
team_id="team-1",
)

assert "openai/gpt-4o" in result
assert captured_params == {
"provider": "openai",
"api_key": "stored-openai-key",
"api_base": "https://example.openai.test/v1",
"credential_name": None,
}


def test_wildcard_credential_hydration_preserves_deployment_params(
monkeypatch,
):
import litellm
from litellm.proxy.auth import model_checks
from litellm.proxy.auth.model_checks import get_known_models_from_wildcard
from litellm.types.router import LiteLLM_Params
from litellm.types.utils import CredentialItem

monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai-credential",
credential_info={"provider": "openai"},
credential_values={
"api_key": "stored-openai-key",
"api_version": "credential-version",
"model": "openai/wrong-model",
"unexpected_field": "unexpected-value",
},
)
],
)

captured_params = {}

def fake_get_provider_models(provider, litellm_params=None):
captured_params["provider"] = provider
captured_params["model"] = litellm_params.model
captured_params["api_key"] = litellm_params.api_key
captured_params["api_version"] = litellm_params.api_version
captured_params["credential_name"] = litellm_params.litellm_credential_name
captured_params["has_unexpected_field"] = hasattr(
litellm_params, "unexpected_field"
)
return ["gpt-4o"]

monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models)

result = get_known_models_from_wildcard(
wildcard_model="openai/*",
litellm_params=LiteLLM_Params(
model="openai/*",
custom_llm_provider="openai",
api_version="deployment-version",
litellm_credential_name="openai-credential",
),
)

assert result == ["openai/gpt-4o"]
assert captured_params == {
"provider": "openai",
"model": "openai/*",
"api_key": "stored-openai-key",
"api_version": "deployment-version",
"credential_name": None,
"has_unexpected_field": False,
}


def test_wildcard_credential_hydration_preserves_missing_credential_name(
monkeypatch,
):
import litellm
from litellm.proxy.auth import model_checks
from litellm.proxy.auth.model_checks import get_known_models_from_wildcard
from litellm.types.router import LiteLLM_Params

monkeypatch.setattr(litellm, "credential_list", [])

captured_params = {}

def fake_get_provider_models(provider, litellm_params=None):
captured_params["provider"] = provider
captured_params["api_key"] = litellm_params.api_key
captured_params["credential_name"] = litellm_params.litellm_credential_name
return ["gpt-4o"]

monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models)

result = get_known_models_from_wildcard(
wildcard_model="openai/*",
litellm_params=LiteLLM_Params(
model="openai/*",
custom_llm_provider="openai",
api_key=None,
litellm_credential_name="missing-credential",
),
)

assert result == ["openai/gpt-4o"]
assert captured_params == {
"provider": "openai",
"api_key": None,
"credential_name": "missing-credential",
}


@pytest.mark.asyncio
async def test_get_available_models_for_user_expands_query_team_wildcard(
monkeypatch,
):
import litellm
from litellm import Router
from litellm.proxy.auth import model_checks
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.utils import get_available_models_for_user
from litellm.types.utils import CredentialItem

monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="openai-credential",
credential_info={"provider": "openai"},
credential_values={"api_key": "stored-openai-key"},
)
],
)

def fake_get_provider_models(provider, litellm_params=None):
assert litellm_params.api_key == "stored-openai-key"
assert litellm_params.litellm_credential_name is None
return ["gpt-4o-mini"]

monkeypatch.setattr(model_checks, "get_provider_models", fake_get_provider_models)

router = Router(
model_list=[
{
"model_name": "model_name_team-1_generated",
"litellm_params": {
"model": "openai/*",
"custom_llm_provider": "openai",
"litellm_credential_name": "openai-credential",
},
"model_info": {
"team_id": "team-1",
"team_public_model_name": "openai/*",
},
}
]
)

result = await get_available_models_for_user(
user_api_key_dict=UserAPIKeyAuth(
api_key="sk-test",
models=[],
team_id="team-1",
team_models=["openai/*"],
),
llm_router=router,
general_settings={},
user_model=None,
team_id="team-1",
)

assert "openai/gpt-4o-mini" in result
Loading