Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
43054a2
fix: langfuse trace leak key on model params
Harshit28j Feb 26, 2026
20bf3aa
fix: pop sensitive keys from langfuse
Harshit28j Mar 10, 2026
9f15a99
Merge branch 'main' into litellm_langfuse_key_leakage
Harshit28j Mar 10, 2026
feee689
fix: set oauth2_flow when building MCPServer in _execute_with_mcp_client
joereyna Mar 12, 2026
377b79a
fix: add oauth2_flow to NewMCPServerRequest and guard auto-detect wit…
joereyna Mar 13, 2026
cd7b258
fix: narrow oauth2_flow type to Literal in NewMCPServerRequest
joereyna Mar 14, 2026
aa744fb
Merge branch 'main' into fix/mcp-rest-m2m-oauth2-flow
joereyna Mar 16, 2026
5d33cc6
Add unit tests for 5 previously untested UI dashboard files
yuneng-jiang Mar 16, 2026
0c17393
fix: remove skip decorators from m2m tests now that oauth2_flow is set
joereyna Mar 16, 2026
eba8df5
Merge pull request #23773 from BerriAI/litellm_/reverent-panini
yuneng-jiang Mar 16, 2026
bc810f9
[Fix] Privilege escalation: restrict /key/block, /key/unblock, and ma…
yuneng-jiang Mar 16, 2026
55c7ba9
Update litellm/proxy/management_endpoints/key_management_endpoints.py
yuneng-jiang Mar 16, 2026
2d98b49
Merge pull request #23781 from BerriAI/litellm_key_admin_privilege_es…
yuneng-jiang Mar 16, 2026
d58b0a9
fix: clear oauth2_flow when client_credentials set without token_url
joereyna Mar 16, 2026
c37cf23
Merge remote-tracking branch 'origin' into litellm_ui_logs_filter_2
yuneng-jiang Mar 16, 2026
e4c8f95
Merge pull request #23468 from joereyna/fix/mcp-rest-m2m-oauth2-flow
yuneng-jiang Mar 16, 2026
57bba3b
[Fix] UI - Logs: Fix empty filter results showing stale data
yuneng-jiang Mar 16, 2026
c951b33
[Fix] Reapply empty filter fix after merge with main
yuneng-jiang Mar 16, 2026
31a677e
Merge pull request #23792 from BerriAI/litellm_ui_logs_filter_2
yuneng-jiang Mar 16, 2026
2ebea08
Merge remote-tracking branch 'origin/main' into litellm_internal_dev_…
yuneng-jiang Mar 16, 2026
bc752fb
[Fix] Prevent internal users from creating invalid keys via key/gener…
yuneng-jiang Mar 17, 2026
208740a
[Fix] Remove duplicate get_team_object call in _validate_update_key_data
yuneng-jiang Mar 17, 2026
c8c4774
Merge pull request #23795 from BerriAI/litellm_fix_internal_user_inva…
yuneng-jiang Mar 17, 2026
4a92db8
[Fix] Skip key_alias re-validation on update/regenerate when alias un…
yuneng-jiang Mar 17, 2026
616b311
Merge pull request #23798 from BerriAI/litellm_skip_alias_revalidatio…
yuneng-jiang Mar 17, 2026
a771fe5
[Fix] Update log filter test to match empty-result behavior
yuneng-jiang Mar 17, 2026
53d96c8
[Feature] Disable custom API key values via UI setting
yuneng-jiang Mar 17, 2026
72aa5fc
[Fix] Add disable_custom_api_keys to UISettings Pydantic model
yuneng-jiang Mar 17, 2026
c687e63
[Feature] Add disable_custom_api_keys toggle to UI Settings page
yuneng-jiang Mar 17, 2026
0b0fe7e
[Fix] Rename toggle label to "Disable custom Virtual key values"
yuneng-jiang Mar 17, 2026
471e0f1
[Fix] Remove "API" from custom key description text
yuneng-jiang Mar 17, 2026
a087c44
Merge pull request #23812 from BerriAI/litellm_disable_custom_api_keys
yuneng-jiang Mar 17, 2026
ad62071
Merge pull request #22188 from BerriAI/litellm_langfuse_key_leakage
yuneng-jiang Mar 17, 2026
dcbaa05
Merge pull request #23826 from BerriAI/litellm_yj_march_16_2026
yuneng-jiang Mar 17, 2026
467706e
Revert "fix: langfuse trace leak key on model params"
yuneng-jiang Mar 17, 2026
b4c9c8a
Merge pull request #23868 from BerriAI/revert-22188-litellm_langfuse_…
yuneng-jiang Mar 17, 2026
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
16 changes: 10 additions & 6 deletions litellm/proxy/_experimental/mcp_server/rest_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,13 +903,17 @@ async def _execute_with_mcp_client(
try:
client_id, client_secret, scopes = _extract_credentials(request)

_oauth2_flow: Optional[
Literal["client_credentials", "authorization_code"]
] = (
"client_credentials"
if client_id and client_secret and request.token_url
else None
_oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = (
request.oauth2_flow or (
"client_credentials"
if client_id and client_secret and request.token_url
else None
)
)
# client_credentials requires token_url to fetch a token; without it the
# incoming auth header would be dropped with nothing to replace it.
if _oauth2_flow == "client_credentials" and not request.token_url:
_oauth2_flow = None

server_model = MCPServer(
server_id=request.server_id or "",
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
is_byok: bool = False
Expand Down
188 changes: 180 additions & 8 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team,
_is_user_team_admin,
_set_object_metadata_field,
)
Expand All @@ -71,6 +72,9 @@
)
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.spend_tracking.spend_tracking_utils import _is_master_key
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
get_ui_settings_cached,
)
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
Expand All @@ -95,6 +99,24 @@
)


async def _check_custom_key_allowed(custom_key_value: Optional[str]) -> None:
"""Raise 403 if custom API keys are disabled and a custom key was provided."""
if custom_key_value is None:
return

ui_settings = await get_ui_settings_cached()
if ui_settings.get("disable_custom_api_keys", False) is True:
verbose_proxy_logger.warning(
"Custom API key rejected: disable_custom_api_keys is enabled"
)
raise HTTPException(
status_code=403,
detail={
"error": "Custom API key values are disabled by your administrator. Keys must be auto-generated."
},
)


def _is_team_key(data: Union[GenerateKeyRequest, LiteLLM_VerificationToken]):
return data.team_id is not None

Expand Down Expand Up @@ -353,14 +375,24 @@

## check if key is for team or individual
is_team_key = _is_team_key(data=data)
_is_admin = (
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if is_team_key:
if team_table is None and litellm.key_generation_settings is not None:
raise HTTPException(
status_code=400,
detail=f"Unable to find team object in database. Team ID: {data.team_id}",
)
elif team_table is None:
return True # assume user is assigning team_id without using the team table
if _is_admin:
return True # admins can assign team_id without team table
# Non-admin callers must have a valid team (LIT-1884)
raise HTTPException(
status_code=400,
detail=f"Unable to find team object in database. Team ID: {data.team_id}",
)
Comment on lines 388 to +395
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.

P1 Backwards-incompatible breaking change for non-admin key creation

Before this PR, when team_table is None (i.e., the team doesn't exist in DB) and key_generation_settings is not configured, the code returned True for all callers — meaning non-admin users could create keys with arbitrary team_id values that didn't correspond to a real team. This PR now raises a 400 for non-admins in that case.

This is a security hardening fix, but it is a backwards-incompatible change for any non-admin users currently relying on the old permissive behaviour (e.g. creating keys tagged with a team_id that hasn't been registered in the team table). Per the project's policy on backwards-incompatible changes, new restrictions like this should be gated behind a user-controlled flag so that existing deployments aren't silently broken on upgrade.

Consider introducing a feature flag (e.g. litellm.require_valid_team_for_key_generation) and defaulting it to False so the behaviour is opt-in, with plans to flip the default in a future major release.

Rule Used: What: avoid backwards-incompatible changes without... (source)

return _team_key_generation_check(
team_table=team_table,
user_api_key_dict=user_api_key_dict,
Expand Down Expand Up @@ -660,6 +692,9 @@
prisma_client=prisma_client,
)

# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.key)

# Validate user-provided key format
if data.key is not None and not data.key.startswith("sk-"):
_masked = (
Expand Down Expand Up @@ -1213,6 +1248,19 @@
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=message
)
# 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 = (
user_api_key_dict.user_role is not None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)
if not _is_proxy_admin and data.user_id is None:
data.user_id = user_api_key_dict.user_id
verbose_proxy_logger.warning(
"key/generate: auto-assigning user_id=%s for non-admin caller",
user_api_key_dict.user_id,

Check failure

Code scanning / CodeQL

Log Injection High

This log entry depends on a
user-provided value
.

Copilot Autofix

AI 14 days ago

To fix log injection, sanitize the potentially user-controlled value before logging it. For plain-text logs, this typically means removing newline and carriage-return characters (and optionally other control characters) from user input before it is passed to the logger. This preserves the value for identification while preventing a malicious user from injecting extra log lines or manipulating the log structure.

In this file, the only problematic sink identified is the warning at lines 1259–1262:

verbose_proxy_logger.warning(
    "key/generate: auto-assigning user_id=%s for non-admin caller",
    user_api_key_dict.user_id,
)

We can fix it locally by creating a sanitized version of user_api_key_dict.user_id just for logging. A simple and non-breaking approach is to convert to str and remove \r and \n. This does not change any functional behavior of the endpoint, only how the value is represented in the log line. No new imports are required.

Concretely:

  • Immediately before the verbose_proxy_logger.warning call, define a new variable, e.g. safe_user_id = str(user_api_key_dict.user_id).replace("\r", "").replace("\n", "").
  • Pass safe_user_id to the logger instead of user_api_key_dict.user_id.

No other parts of the function or file need to be changed to address this specific alert.

Suggested changeset 1
litellm/proxy/management_endpoints/key_management_endpoints.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py
--- a/litellm/proxy/management_endpoints/key_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/key_management_endpoints.py
@@ -1256,9 +1256,10 @@
         )
         if not _is_proxy_admin and data.user_id is None:
             data.user_id = user_api_key_dict.user_id
+            safe_user_id = str(user_api_key_dict.user_id).replace("\r", "").replace("\n", "")
             verbose_proxy_logger.warning(
                 "key/generate: auto-assigning user_id=%s for non-admin caller",
-                user_api_key_dict.user_id,
+                safe_user_id,
             )
 
         team_table: Optional[LiteLLM_TeamTableCachedObj] = None
EOF
@@ -1256,9 +1256,10 @@
)
if not _is_proxy_admin and data.user_id is None:
data.user_id = user_api_key_dict.user_id
safe_user_id = str(user_api_key_dict.user_id).replace("\r", "").replace("\n", "")
verbose_proxy_logger.warning(
"key/generate: auto-assigning user_id=%s for non-admin caller",
user_api_key_dict.user_id,
safe_user_id,
)

team_table: Optional[LiteLLM_TeamTableCachedObj] = None
Copilot is powered by AI and may make mistakes. Always verify output.
)

team_table: Optional[LiteLLM_TeamTableCachedObj] = None
if data.team_id is not None:
try:
Expand All @@ -1227,6 +1275,12 @@
verbose_proxy_logger.debug(
f"Error getting team object in `/key/generate`: {e}"
)
# For non-admin callers, team must exist (LIT-1884)
if not _is_proxy_admin:
raise HTTPException(
status_code=400,
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot create keys for non-existent teams.",
)

key_generation_check(
team_table=team_table,
Expand Down Expand Up @@ -1809,11 +1863,26 @@
user_api_key_cache: Any,
) -> None:
"""Validate permissions and constraints for key update."""
_is_proxy_admin = (
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
)

# 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(
status_code=403,
detail="Non-admin users cannot remove the user_id from a key.",
)

# sanity check - prevent non-proxy admin user from updating key to belong to a different user
if (
data.user_id is not None
and data.user_id != existing_key_row.user_id
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
and not _is_proxy_admin
):
raise HTTPException(
status_code=403,
Expand All @@ -1836,6 +1905,18 @@
user_api_key_cache=user_api_key_cache,
)

# Admin-only: only proxy admins, team admins, or org admins can modify max_budget
if data.max_budget is not None and data.max_budget != existing_key_row.max_budget:
if prisma_client is not None:
hashed_key = existing_key_row.token
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_key,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/update (max_budget)",
)
Comment on lines +1908 to +1918
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.

P1 Backwards-incompatible restriction on max_budget updates for non-admins

Prior to this PR, any user who had the key ownership permissions could update max_budget on their own key. Now only proxy admins, team admins, or org admins can do so (enforced via _check_key_admin_access). This is a new restriction on previously-allowed behaviour.

For users who currently auto-provision keys with budgets via non-admin service accounts, this will silently start returning 403 after upgrade. Per the project policy on backwards-incompatible changes, this restriction should be gated behind a feature flag (e.g. litellm.restrict_max_budget_updates_to_admins) so existing deployments are not broken on upgrade.

Rule Used: What: avoid backwards-incompatible changes without... (source)


# Check team limits if key has a team_id (from request or existing key)
team_obj: Optional[LiteLLM_TeamTableCachedObj] = None
_team_id_to_check = data.team_id or getattr(existing_key_row, "team_id", None)
Expand All @@ -1847,6 +1928,13 @@
check_db_only=True,
)

# Validate team exists when non-admin sets a new team_id (LIT-1884)
if team_obj is None and data.team_id is not None and not _is_proxy_admin:
raise HTTPException(
status_code=400,
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.",
)

if team_obj is not None:
await _check_team_key_limits(
team_table=team_obj,
Expand Down Expand Up @@ -2056,7 +2144,10 @@
data=data, existing_key_row=existing_key_row
)

_validate_key_alias_format(key_alias=non_default_values.get("key_alias", None))
# Only validate key_alias format if it's actually being changed
new_key_alias = non_default_values.get("key_alias", None)
if new_key_alias != existing_key_row.key_alias:
_validate_key_alias_format(key_alias=new_key_alias)

await _enforce_unique_key_alias(
key_alias=non_default_values.get("key_alias", None),
Expand Down Expand Up @@ -3412,8 +3503,10 @@
)


def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
if data and data.new_key is not None:
# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.new_key)
new_token = data.new_key
if not data.new_key.startswith("sk-"):
raise HTTPException(
Expand Down Expand Up @@ -3505,7 +3598,7 @@
"""Generate new token, update DB, invalidate cache, and return response."""
from litellm.proxy.proxy_server import hash_token

new_token = get_new_token(data=data)
new_token = await get_new_token(data=data)
new_token_hash = hash_token(new_token)
new_token_key_name = f"sk-...{new_token[-4:]}"
update_data = {"token": new_token_hash, "key_name": new_token_key_name}
Expand All @@ -3515,7 +3608,10 @@
non_default_values = await prepare_key_update_data(
data=data, existing_key_row=key_in_db
)
_validate_key_alias_format(key_alias=non_default_values.get("key_alias"))
# Only validate key_alias format if it's actually being changed
new_key_alias = non_default_values.get("key_alias")
if new_key_alias != key_in_db.key_alias:
_validate_key_alias_format(key_alias=new_key_alias)
verbose_proxy_logger.debug("non_default_values: %s", non_default_values)
update_data.update(non_default_values)
update_data = prisma_client.jsonify_object(data=update_data)
Expand Down Expand Up @@ -4733,6 +4829,64 @@
}


async def _check_key_admin_access(
user_api_key_dict: UserAPIKeyAuth,
hashed_token: str,
prisma_client: Any,
user_api_key_cache: DualCache,
route: str,
) -> None:
"""
Check that the caller has admin privileges for the target key.

Allowed callers:
- Proxy admin
- Team admin for the key's team
- Org admin for the key's team's organization

Raises HTTPException(403) if the caller is not authorized.
"""

if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return

# Look up the target key to find its team
target_key_row = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": hashed_token}
)
Comment on lines +4854 to +4856
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.

P1 Direct DB query bypasses helper function; causes redundant round-trip

prisma_client.db.litellm_verificationtoken.find_unique is a direct Prisma query rather than going through get_key_object, which is the designated helper per the project's DB access guidelines.

Additionally, when _check_key_admin_access is called from _validate_update_key_data for max_budget changes (line ~1912), the caller already holds existing_key_row — which contains team_id — so passing hashed_token and re-fetching the same row is an unnecessary extra DB round-trip. Consider accepting an optional team_id parameter so that callers who already have the row in memory can skip the lookup entirely.

Rule Used: What: In critical path of request, there should be... (source)

if target_key_row is None:
raise HTTPException(
status_code=404,
detail={"error": f"Key not found: {hashed_token}"},
)

# If the key belongs to a team, check team admin / org admin
if target_key_row.team_id:
team_obj = await get_team_object(
team_id=target_key_row.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
if team_obj is not None:
if _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return
if await _is_user_org_admin_for_team(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
return

raise HTTPException(
status_code=403,
detail={
"error": f"Only proxy admins, team admins, or org admins can call {route}. "
f"user_role={user_api_key_dict.user_role}, user_id={user_api_key_dict.user_id}"
},
)


@router.post(
"/key/block", tags=["key management"], dependencies=[Depends(user_api_key_auth)]
)
Expand Down Expand Up @@ -4762,7 +4916,7 @@
}'
```

Note: This is an admin-only endpoint. Only proxy admins can block keys.
Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can block keys.
"""
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
Expand All @@ -4788,6 +4942,15 @@
else:
hashed_token = data.key

# Admin-only: only proxy admins, team admins, or org admins can block keys
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/block",
)

if litellm.store_audit_logs is True:
# make an audit log for key update
record = await prisma_client.db.litellm_verificationtoken.find_unique(
Expand Down Expand Up @@ -4876,7 +5039,7 @@
}'
```

Note: This is an admin-only endpoint. Only proxy admins can unblock keys.
Note: This is an admin-only endpoint. Only proxy admins, team admins, or org admins can unblock keys.
"""
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
Expand All @@ -4902,6 +5065,15 @@
else:
hashed_token = data.key

# Admin-only: only proxy admins, team admins, or org admins can unblock keys
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
hashed_token=hashed_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
route="/key/unblock",
)

if litellm.store_audit_logs is True:
# make an audit log for key update
record = await prisma_client.db.litellm_verificationtoken.find_unique(
Expand Down
6 changes: 6 additions & 0 deletions litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ class UISettings(BaseModel):
description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.",
)

disable_custom_api_keys: bool = Field(
default=False,
description="If true, users cannot specify custom key values. All keys must be auto-generated.",
)


class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
Expand All @@ -149,6 +154,7 @@ class UISettingsResponse(SettingsResponse):
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"scope_user_search_to_org",
"disable_custom_api_keys",
}

# Flags that must be synced from the persisted UISettings into
Expand Down
Loading
Loading