VYPR
High severity8.8NVD Advisory· Published May 21, 2026

CVE-2026-47101

CVE-2026-47101

Description

LiteLLM prior to 1.83.14 allows an authenticated internal_user to create API keys with access to routes that their role does not permit. When generating a key, the allowed_routes field is stored without verifying that the specified routes fall within the user's own permissions. A key created with access to admin-only routes can then be used to reach those routes successfully, bypassing the role-based access controls that would otherwise block the request, enabling full privilege escalation from internal_user to proxy_admin.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

LiteLLM before 1.83.14 allows an internal_user to create API keys with admin-only routes due to missing permission checks, enabling full privilege escalation to proxy_admin.

A privilege escalation vulnerability in LiteLLM prior to version 1.83.14 allows an authenticated internal_user to create API keys with arbitrary allowed_routes, including routes restricted to proxy_admin role. The /key/generate endpoint fails to verify that the specified routes fall within the user's own permissions when storing the key in the LiteLLM_VerificationToken table [2]. This bypasses the role-based access control enforced by non_proxy_admin_allowed_routes_check() in route_checks.py [2].

An attacker with internal_user privileges can generate a key with allowed_routes set to admin-only endpoints such as /user/update. Using this key, they can modify their own user_role to proxy_admin, effectively escalating privileges to full administrative control [2]. The exploitation requires only that the attacker has an authenticated session with key management permissions, which is standard for internal_user [2].

The impact is complete compromise of the LiteLLM proxy: the attacker gains access to all administrative routes, including user management, team configuration, and proxy settings. This can lead to data exfiltration, service disruption, or lateral movement within the infrastructure [2] [3] [4].

The vulnerability is fixed in release v1.83.14-stable (published May 21, 2026) [1]. The fix adds a _check_allowed_routes_caller_permission function that restricts setting allowed_routes to only proxy_admin users, with an exception for safe presets like llm_api_routes and info_routes [3] [4]. Users are advised to upgrade immediately; no workaround is available for older versions.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Berriai/Litellmreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <1.83.14

Patches

3
5190bd07eb23

fix: extend caller-permission checks to service-account + harden raw-body acceptance

https://github.com/BerriAI/litellmYuneng JiangApr 25, 2026via nvd-ref
1 file changed · +33 6
  • litellm/proxy/management_endpoints/key_management_endpoints.py+33 6 modified
    @@ -462,22 +462,29 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict:
     def _check_allowed_routes_caller_permission(
         allowed_routes: Optional[list],
         user_api_key_dict: UserAPIKeyAuth,
    +    *,
    +    allow_safe_presets: bool = False,
     ) -> None:
         """
    -    Only proxy admins may set `allowed_routes` on a key, except for the safe
    -    presets produced by `handle_key_type` for non-elevated buckets
    -    (`llm_api_routes`, `info_routes`).
    +    Only proxy admins may set `allowed_routes` on a key.
     
         `allowed_routes` overrides the standard role-based route gate in
         RouteChecks.non_proxy_admin_allowed_routes_check, so the field is
    -    restricted to admins outside of those safe presets.
    +    restricted to admins. Non-admins must instead use `key_type` to pick a
    +    preset bucket — that path goes through `handle_key_type` and re-enters
    +    this function with `allow_safe_presets=True`, which lets the derived
    +    `llm_api_routes` / `info_routes` values through. Raw-body call sites
    +    leave `allow_safe_presets=False` so non-admins can't write those values
    +    directly.
         """
         # Empty list is the default on GenerateKeyRequest — treat as "not set".
         if not allowed_routes:
             return
         if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
             return
    -    if all(r in _NON_ADMIN_SAFE_ALLOWED_ROUTES_PRESETS for r in allowed_routes):
    +    if allow_safe_presets and all(
    +        r in _NON_ADMIN_SAFE_ALLOWED_ROUTES_PRESETS for r in allowed_routes
    +    ):
             return
         raise HTTPException(
             status_code=403,
    @@ -712,10 +719,13 @@ async def _common_key_generation_helper(  # noqa: PLR0915
     
         # Re-check allowed_routes after handle_key_type, since key_type can derive
         # an elevated bucket (e.g. ["management_routes"]) that wasn't present in
    -    # the original request body.
    +    # the original request body. The safe presets produced by handle_key_type
    +    # for non-elevated buckets are accepted here; the raw-body pre-checks at
    +    # the entry of each handler keep their default strictness.
         _check_allowed_routes_caller_permission(
             allowed_routes=data_json.get("allowed_routes"),
             user_api_key_dict=user_api_key_dict,
    +        allow_safe_presets=True,
         )
     
         # if we get max_budget passed to /key/generate, then use it as key_max_budget. Since generate_key_helper_fn is used to make new users
    @@ -1499,6 +1509,15 @@ async def generate_service_account_key_fn(
                 detail={"error": CommonProxyErrors.db_not_connected_error.value},
             )
     
    +    _check_allowed_routes_caller_permission(
    +        allowed_routes=data.allowed_routes,
    +        user_api_key_dict=user_api_key_dict,
    +    )
    +    _check_passthrough_routes_caller_permission(
    +        data=data,
    +        user_api_key_dict=user_api_key_dict,
    +    )
    +
         await validate_team_id_used_in_service_account_request(
             team_id=data.team_id,
             prisma_client=prisma_client,
    @@ -3950,6 +3969,14 @@ async def regenerate_key_fn(  # noqa: PLR0915
                     data=data,
                     user_api_key_dict=user_api_key_dict,
                 )
    +            # Mirror /key/generate's post-handle_key_type recheck so a
    +            # non-admin can't elevate via a key_type preset that the
    +            # regenerate flow would otherwise carry through unchecked.
    +            _check_allowed_routes_caller_permission(
    +                allowed_routes=handle_key_type(data, {}).get("allowed_routes"),
    +                user_api_key_dict=user_api_key_dict,
    +                allow_safe_presets=True,
    +            )
     
             is_master_key_regeneration = data and data.new_master_key is not None
     
    
2220f3076ac8

fix: tighten caller-permission checks on key route fields

https://github.com/BerriAI/litellmYuneng JiangApr 25, 2026via nvd-ref
1 file changed · +67 5
  • litellm/proxy/management_endpoints/key_management_endpoints.py+67 5 modified
    @@ -456,23 +456,29 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict:
         return data_json
     
     
    +_NON_ADMIN_SAFE_ALLOWED_ROUTES_PRESETS = frozenset({"llm_api_routes", "info_routes"})
    +
    +
     def _check_allowed_routes_caller_permission(
         allowed_routes: Optional[list],
         user_api_key_dict: UserAPIKeyAuth,
     ) -> None:
         """
    -    Only proxy admins may set `allowed_routes` on a key.
    +    Only proxy admins may set `allowed_routes` on a key, except for the safe
    +    presets produced by `handle_key_type` for non-elevated buckets
    +    (`llm_api_routes`, `info_routes`).
     
    -    `allowed_routes` bypasses the standard role-based route gate in
    -    RouteChecks.non_proxy_admin_allowed_routes_check, so if a non-admin is
    -    allowed to set it they can grant themselves access to any endpoint.
    -    Non-admins should use `key_type` to pick a preset route bucket instead.
    +    `allowed_routes` overrides the standard role-based route gate in
    +    RouteChecks.non_proxy_admin_allowed_routes_check, so the field is
    +    restricted to admins outside of those safe presets.
         """
         # Empty list is the default on GenerateKeyRequest — treat as "not set".
         if not allowed_routes:
             return
         if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
             return
    +    if all(r in _NON_ADMIN_SAFE_ALLOWED_ROUTES_PRESETS for r in allowed_routes):
    +        return
         raise HTTPException(
             status_code=403,
             detail={
    @@ -484,6 +490,36 @@ def _check_allowed_routes_caller_permission(
         )
     
     
    +def _check_passthrough_routes_caller_permission(
    +    data: BaseModel,
    +    user_api_key_dict: UserAPIKeyAuth,
    +) -> None:
    +    """
    +    Only proxy admins may set `allowed_passthrough_routes` on a key, either at
    +    the top level of the request or nested under `metadata`.
    +
    +    The route gate evaluates passthrough access ahead of the standard role
    +    gate, so the field is restricted to admins to keep that ordering safe.
    +    """
    +    if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
    +        return
    +    if getattr(data, "allowed_passthrough_routes", None):
    +        raise HTTPException(
    +            status_code=403,
    +            detail={
    +                "error": "Only proxy admins can set `allowed_passthrough_routes` on a key."
    +            },
    +        )
    +    metadata = getattr(data, "metadata", None)
    +    if isinstance(metadata, dict) and metadata.get("allowed_passthrough_routes"):
    +        raise HTTPException(
    +            status_code=403,
    +            detail={
    +                "error": "Only proxy admins can set `metadata.allowed_passthrough_routes` on a key."
    +            },
    +        )
    +
    +
     async def validate_team_id_used_in_service_account_request(
         team_id: Optional[str],
         prisma_client: Optional[PrismaClient],
    @@ -674,6 +710,14 @@ async def _common_key_generation_helper(  # noqa: PLR0915
     
         data_json = handle_key_type(data, data_json)
     
    +    # Re-check allowed_routes after handle_key_type, since key_type can derive
    +    # an elevated bucket (e.g. ["management_routes"]) that wasn't present in
    +    # the original request body.
    +    _check_allowed_routes_caller_permission(
    +        allowed_routes=data_json.get("allowed_routes"),
    +        user_api_key_dict=user_api_key_dict,
    +    )
    +
         # if we get max_budget passed to /key/generate, then use it as key_max_budget. Since generate_key_helper_fn is used to make new users
         if "max_budget" in data_json:
             data_json["key_max_budget"] = data_json.pop("max_budget", None)
    @@ -1288,6 +1332,10 @@ async def generate_key_fn(
                 allowed_routes=data.allowed_routes,
                 user_api_key_dict=user_api_key_dict,
             )
    +        _check_passthrough_routes_caller_permission(
    +            data=data,
    +            user_api_key_dict=user_api_key_dict,
    +        )
     
             # For non-admin internal users: auto-assign caller's user_id if not provided
             # This prevents creating unbound keys with no user association (LIT-1884)
    @@ -1940,6 +1988,10 @@ async def _validate_update_key_data(
             allowed_routes=data.allowed_routes,
             user_api_key_dict=user_api_key_dict,
         )
    +    _check_passthrough_routes_caller_permission(
    +        data=data,
    +        user_api_key_dict=user_api_key_dict,
    +    )
     
         # Prevent non-admin from removing user_id (setting to empty string) (LIT-1884)
         if data.user_id is not None and data.user_id == "" and not _is_proxy_admin:
    @@ -3889,6 +3941,16 @@ async def regenerate_key_fn(  # noqa: PLR0915
                 user_api_key_cache,
             )
     
    +        if data is not None:
    +            _check_allowed_routes_caller_permission(
    +                allowed_routes=data.allowed_routes,
    +                user_api_key_dict=user_api_key_dict,
    +            )
    +            _check_passthrough_routes_caller_permission(
    +                data=data,
    +                user_api_key_dict=user_api_key_dict,
    +            )
    +
             is_master_key_regeneration = data and data.new_master_key is not None
     
             if (
    
d910a95661fc

fix(proxy): improve input validation on management endpoints

https://github.com/BerriAI/litellmjaydenApr 9, 2026via nvd-ref
4 files changed · +258 21
  • litellm/integrations/dotprompt/prompt_manager.py+6 2 modified
    @@ -7,7 +7,8 @@
     from typing import Any, Dict, List, Optional, Tuple, Union
     
     import yaml
    -from jinja2 import DictLoader, Environment, select_autoescape
    +from jinja2 import DictLoader, select_autoescape
    +from jinja2.sandbox import ImmutableSandboxedEnvironment
     
     
     class PromptTemplate:
    @@ -59,7 +60,10 @@ def __init__(
             self.prompt_directory = Path(prompt_directory) if prompt_directory else None
             self.prompts: Dict[str, PromptTemplate] = {}
             self.prompt_file = prompt_file
    -        self.jinja_env = Environment(
    +        # Sandboxed env: templates can come from user input via /prompts/test,
    +        # so we must block access to unsafe Python attributes and mutation of
    +        # caller-supplied mutables.
    +        self.jinja_env = ImmutableSandboxedEnvironment(
                 loader=DictLoader({}),
                 autoescape=select_autoescape(["html", "xml"]),
                 # Use Handlebars-style delimiters to match Dotprompt spec
    
  • litellm/proxy/management_endpoints/key_management_endpoints.py+58 19 modified
    @@ -456,6 +456,34 @@ def handle_key_type(data: GenerateKeyRequest, data_json: dict) -> dict:
         return data_json
     
     
    +def _check_allowed_routes_caller_permission(
    +    allowed_routes: Optional[list],
    +    user_api_key_dict: UserAPIKeyAuth,
    +) -> None:
    +    """
    +    Only proxy admins may set `allowed_routes` on a key.
    +
    +    `allowed_routes` bypasses the standard role-based route gate in
    +    RouteChecks.non_proxy_admin_allowed_routes_check, so if a non-admin is
    +    allowed to set it they can grant themselves access to any endpoint.
    +    Non-admins should use `key_type` to pick a preset route bucket instead.
    +    """
    +    # Empty list is the default on GenerateKeyRequest — treat as "not set".
    +    if not allowed_routes:
    +        return
    +    if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
    +        return
    +    raise HTTPException(
    +        status_code=403,
    +        detail={
    +            "error": (
    +                "Only proxy admins can set `allowed_routes` on a key. "
    +                "Use `key_type` to pick a preset route bucket instead."
    +            )
    +        },
    +    )
    +
    +
     async def validate_team_id_used_in_service_account_request(
         team_id: Optional[str],
         prisma_client: Optional[PrismaClient],
    @@ -740,9 +768,9 @@ async def _common_key_generation_helper(  # noqa: PLR0915
             request_type="key", **data_json, table_name="key"
         )
     
    -    response[
    -        "soft_budget"
    -    ] = data.soft_budget  # include the user-input soft budget in the response
    +    response["soft_budget"] = (
    +        data.soft_budget
    +    )  # include the user-input soft budget in the response
     
         response = GenerateKeyResponse(**response)
     
    @@ -1254,6 +1282,12 @@ async def generate_key_fn(
                     raise HTTPException(
                         status_code=status.HTTP_403_FORBIDDEN, detail=message
                     )
    +
    +        _check_allowed_routes_caller_permission(
    +            allowed_routes=data.allowed_routes,
    +            user_api_key_dict=user_api_key_dict,
    +        )
    +
             # For non-admin internal users: auto-assign caller's user_id if not provided
             # This prevents creating unbound keys with no user association (LIT-1884)
             _is_proxy_admin = (
    @@ -1888,6 +1922,11 @@ async def _validate_update_key_data(
         """Validate permissions and constraints for key update."""
         _is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
     
    +    _check_allowed_routes_caller_permission(
    +        allowed_routes=data.allowed_routes,
    +        user_api_key_dict=user_api_key_dict,
    +    )
    +
         # Prevent non-admin from removing user_id (setting to empty string) (LIT-1884)
         if data.user_id is not None and data.user_id == "" and not _is_proxy_admin:
             raise HTTPException(
    @@ -3233,10 +3272,10 @@ async def delete_verification_tokens(
         try:
             if prisma_client:
                 tokens = [_hash_token_if_needed(token=key) for key in tokens]
    -            _keys_being_deleted: List[
    -                LiteLLM_VerificationToken
    -            ] = await prisma_client.db.litellm_verificationtoken.find_many(
    -                where={"token": {"in": tokens}}
    +            _keys_being_deleted: List[LiteLLM_VerificationToken] = (
    +                await prisma_client.db.litellm_verificationtoken.find_many(
    +                    where={"token": {"in": tokens}}
    +                )
                 )
     
                 if len(_keys_being_deleted) == 0:
    @@ -3436,9 +3475,9 @@ async def _rotate_master_key(  # noqa: PLR0915
         from litellm.proxy.proxy_server import proxy_config
     
         try:
    -        models: Optional[
    -            List
    -        ] = await prisma_client.db.litellm_proxymodeltable.find_many()
    +        models: Optional[List] = (
    +            await prisma_client.db.litellm_proxymodeltable.find_many()
    +        )
         except Exception:
             models = None
         # 2. process model table
    @@ -4078,11 +4117,11 @@ async def validate_key_list_check(
                 param="user_id",
                 code=status.HTTP_403_FORBIDDEN,
             )
    -    complete_user_info_db_obj: Optional[
    -        BaseModel
    -    ] = await prisma_client.db.litellm_usertable.find_unique(
    -        where={"user_id": user_api_key_dict.user_id},
    -        include={"organization_memberships": True},
    +    complete_user_info_db_obj: Optional[BaseModel] = (
    +        await prisma_client.db.litellm_usertable.find_unique(
    +            where={"user_id": user_api_key_dict.user_id},
    +            include={"organization_memberships": True},
    +        )
         )
     
         if complete_user_info_db_obj is None:
    @@ -4165,10 +4204,10 @@ async def _fetch_user_team_objects(
         if complete_user_info is None or not complete_user_info.teams:
             return []
     
    -    teams: Optional[
    -        List[BaseModel]
    -    ] = await prisma_client.db.litellm_teamtable.find_many(
    -        where={"team_id": {"in": complete_user_info.teams}}
    +    teams: Optional[List[BaseModel]] = (
    +        await prisma_client.db.litellm_teamtable.find_many(
    +            where={"team_id": {"in": complete_user_info.teams}}
    +        )
         )
         if teams is None:
             return []
    
  • litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py+27 0 modified
    @@ -1,6 +1,7 @@
     #### CRUD ENDPOINTS for UI Settings #####
     import json
     from typing import Any, Dict, List, Optional, Union
    +from urllib.parse import urlparse
     
     from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
     
    @@ -817,6 +818,29 @@ async def get_ui_theme_settings():
         )
     
     
    +def _validate_public_image_url(value: Optional[str], field_name: str) -> None:
    +    """
    +    Reject anything that isn't a plain http(s) URL with a host. This value is
    +    later served via the unauthenticated /get_image endpoint, so local paths
    +    like "/etc/passwd" or "file://..." must not be accepted.
    +    """
    +    if value is None:
    +        return
    +    if not isinstance(value, str) or not value.strip():
    +        return
    +    parsed = urlparse(value.strip())
    +    if parsed.scheme not in ("http", "https") or not parsed.netloc:
    +        raise HTTPException(
    +            status_code=400,
    +            detail={
    +                "error": (
    +                    f"Invalid {field_name}: must be an http(s) URL with a host. "
    +                    "Local filesystem paths and non-http schemes are not allowed."
    +                )
    +            },
    +        )
    +
    +
     @router.patch(
         "/update/ui_theme_settings",
         tags=["UI Theme Settings"],
    @@ -831,6 +855,9 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
     
         from litellm.proxy.proxy_server import proxy_config, store_model_in_db
     
    +    _validate_public_image_url(theme_config.logo_url, "logo_url")
    +    _validate_public_image_url(theme_config.favicon_url, "favicon_url")
    +
         if store_model_in_db is not True:
             raise HTTPException(
                 status_code=500,
    
  • tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py+167 0 modified
    @@ -8579,3 +8579,170 @@ def test_enforce_upperbound_no_config_is_noop():
             assert data.tpm_limit == 999999
         finally:
             litellm.upperbound_key_generate_params = original
    +
    +
    +class TestAllowedRoutesCallerPermission:
    +    """
    +    Non-admins must not be able to set `allowed_routes` on a key. The field
    +    bypasses the role-based route gate in
    +    RouteChecks.non_proxy_admin_allowed_routes_check, so allowing a non-admin
    +    to populate it grants them arbitrary endpoint access.
    +    """
    +
    +    @pytest.mark.asyncio
    +    async def test_non_admin_generate_key_with_allowed_routes_rejected(self):
    +        data = GenerateKeyRequest(
    +            key_alias="escalate",
    +            allowed_routes=["/*"],
    +        )
    +        user_api_key_dict = UserAPIKeyAuth(
    +            user_id="internal-user-123",
    +            user_role=LitellmUserRoles.INTERNAL_USER,
    +        )
    +        mock_prisma_client = AsyncMock()
    +
    +        with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
    +            "litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
    +        ), patch("litellm.proxy.proxy_server.user_custom_key_generate", None), patch(
    +            "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
    +            new_callable=AsyncMock,
    +            return_value=MagicMock(),
    +        ):
    +            with pytest.raises(ProxyException) as exc_info:
    +                await generate_key_fn(
    +                    data=data,
    +                    user_api_key_dict=user_api_key_dict,
    +                    litellm_changed_by=None,
    +                )
    +        assert str(exc_info.value.code) == "403"
    +        assert "allowed_routes" in str(exc_info.value.message)
    +
    +    @pytest.mark.asyncio
    +    async def test_admin_generate_key_with_allowed_routes_allowed(self):
    +        data = GenerateKeyRequest(
    +            key_alias="admin-key",
    +            allowed_routes=["/chat/completions"],
    +            user_id="admin-user",
    +        )
    +        user_api_key_dict = UserAPIKeyAuth(
    +            user_id="admin-user",
    +            user_role=LitellmUserRoles.PROXY_ADMIN,
    +        )
    +        mock_prisma_client = AsyncMock()
    +        stub_response = MagicMock()
    +
    +        with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
    +            "litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
    +        ), patch("litellm.proxy.proxy_server.user_custom_key_generate", None), patch(
    +            "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
    +            new_callable=AsyncMock,
    +            return_value=stub_response,
    +        ):
    +            result = await generate_key_fn(
    +                data=data,
    +                user_api_key_dict=user_api_key_dict,
    +                litellm_changed_by=None,
    +            )
    +        assert result is stub_response
    +
    +    @pytest.mark.asyncio
    +    async def test_non_admin_generate_key_default_empty_allowed_routes_ok(self):
    +        """
    +        Regression guard: GenerateKeyRequest.allowed_routes defaults to [], so
    +        the helper must treat empty-list as "not set" or every non-admin key
    +        creation breaks.
    +        """
    +        data = GenerateKeyRequest(key_alias="plain-key")
    +        user_api_key_dict = UserAPIKeyAuth(
    +            user_id="internal-user-123",
    +            user_role=LitellmUserRoles.INTERNAL_USER,
    +        )
    +        mock_prisma_client = AsyncMock()
    +        stub_response = MagicMock()
    +
    +        with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
    +            "litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
    +        ), patch("litellm.proxy.proxy_server.user_custom_key_generate", None), patch(
    +            "litellm.proxy.management_endpoints.key_management_endpoints._common_key_generation_helper",
    +            new_callable=AsyncMock,
    +            return_value=stub_response,
    +        ):
    +            result = await generate_key_fn(
    +                data=data,
    +                user_api_key_dict=user_api_key_dict,
    +                litellm_changed_by=None,
    +            )
    +        assert result is stub_response
    +
    +    @pytest.mark.asyncio
    +    async def test_non_admin_update_key_with_allowed_routes_rejected(self):
    +        from litellm.proxy.management_endpoints.key_management_endpoints import (
    +            update_key_fn,
    +        )
    +
    +        data = UpdateKeyRequest(key="sk-test", allowed_routes=["/*"])
    +        user_api_key_dict = UserAPIKeyAuth(
    +            user_id="internal-user-123",
    +            user_role=LitellmUserRoles.INTERNAL_USER,
    +        )
    +        mock_prisma_client = AsyncMock()
    +
    +        with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), patch(
    +            "litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
    +        ), patch("litellm.proxy.proxy_server.user_custom_key_update", None), patch(
    +            "litellm.proxy.proxy_server.llm_router", None
    +        ), patch("litellm.proxy.proxy_server.premium_user", True), patch(
    +            "litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()
    +        ), patch(
    +            "litellm.proxy.management_endpoints.key_management_endpoints._get_and_validate_existing_key",
    +            new_callable=AsyncMock,
    +            return_value=MagicMock(),
    +        ):
    +            with pytest.raises(ProxyException) as exc_info:
    +                await update_key_fn(
    +                    request=MagicMock(),
    +                    data=data,
    +                    user_api_key_dict=user_api_key_dict,
    +                    litellm_changed_by=None,
    +                )
    +        assert str(exc_info.value.code) == "403"
    +        assert "allowed_routes" in str(exc_info.value.message)
    +
    +
    +def test_jinja_prompt_manager_is_sandboxed():
    +    """
    +    PromptManager renders user-supplied templates via /prompts/test, so its
    +    jinja env must reject access to unsafe Python attributes like
    +    ``__class__`` and ``__mro__``.
    +    """
    +    from jinja2.exceptions import SecurityError
    +
    +    from litellm.integrations.dotprompt.prompt_manager import PromptManager
    +
    +    pm = PromptManager()
    +    template = pm.jinja_env.from_string("{{ ''.__class__.__mro__ }}")
    +    with pytest.raises(SecurityError):
    +        template.render()
    +
    +
    +def test_validate_public_image_url_rejects_local_paths():
    +    from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
    +        _validate_public_image_url,
    +    )
    +
    +    for bad in ("/etc/passwd", "file:///etc/passwd", "../../etc/passwd"):
    +        with pytest.raises(HTTPException) as exc_info:
    +            _validate_public_image_url(bad, "logo_url")
    +        assert exc_info.value.status_code == 400
    +
    +
    +def test_validate_public_image_url_accepts_http_and_noop_empty():
    +    from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
    +        _validate_public_image_url,
    +    )
    +
    +    _validate_public_image_url("https://example.com/logo.png", "logo_url")
    +    _validate_public_image_url("http://cdn.internal/logo.svg", "logo_url")
    +    _validate_public_image_url(None, "logo_url")
    +    _validate_public_image_url("", "logo_url")
    +    _validate_public_image_url("   ", "logo_url")
    

Vulnerability mechanics

Root cause

"Missing authorization check on the `allowed_routes` field when creating or updating API keys allows an authenticated internal_user to specify routes that exceed their role's permissions."

Attack vector

An authenticated user with the `internal_user` role sends a request to `/key/generate`, `/key/update`, `/key/regenerate`, or the service-account key endpoint with an `allowed_routes` field containing routes reserved for `proxy_admin` (e.g. `["/*"]` or `["management_routes"]`). The server stores the key with those routes without verifying that the caller's role permits them. The attacker then uses the newly created key to make requests to the admin-only endpoints; because `allowed_routes` overrides the standard role-based route gate in `RouteChecks.non_proxy_admin_allowed_routes_check`, the requests succeed, achieving full privilege escalation to `proxy_admin` [patch_id=1264296][patch_id=1264297][patch_id=1264298].

Affected code

The vulnerability exists in `litellm/proxy/management_endpoints/key_management_endpoints.py` within the `generate_key_fn`, `_validate_update_key_data`, `regenerate_key_fn`, and `generate_service_account_key_fn` functions. These endpoints accepted the `allowed_routes` field from non-admin users without verifying the caller's role permissions. The `_common_key_generation_helper` function also lacked a re-check after `handle_key_type` could derive elevated route buckets [patch_id=1264296][patch_id=1264297].

What the fix does

The patches introduce `_check_allowed_routes_caller_permission()` and `_check_passthrough_routes_caller_permission()` which reject non-admin requests that include `allowed_routes` or `allowed_passthrough_routes` [patch_id=1264296]. These checks are inserted into `generate_key_fn`, `_validate_update_key_data`, `regenerate_key_fn`, and `generate_service_account_key_fn` [patch_id=1264296][patch_id=1264297]. A second patch refines the logic so that non-admins may still use the `key_type` parameter to derive safe preset buckets (`llm_api_routes`, `info_routes`) via `handle_key_type`, but raw-body `allowed_routes` values remain blocked [patch_id=1264297]. The third patch adds comprehensive unit tests confirming that non-admin key generation with `allowed_routes` is rejected while admin usage and default empty lists continue to work [patch_id=1264298].

Preconditions

  • authAttacker must have a valid authenticated session with the internal_user role
  • networkAttacker must be able to reach the /key/generate, /key/update, /key/regenerate, or service-account key management endpoints over the network
  • configThe LiteLLM proxy must be running a version prior to 1.83.14

Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

47