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
11 changes: 11 additions & 0 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
populate_request_with_path_params,
)
from litellm.proxy.common_utils.realtime_utils import _realtime_request_body
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
Expand Down Expand Up @@ -1977,6 +1978,16 @@ async def _run_centralized_common_checks(
llm_router=llm_router,
)

# Merge x-litellm-tags into request_data BEFORE common_checks runs.
# _tag_max_budget_check inside common_checks only inspects request_data;
# without this pre-merge, header-supplied tags bypass tag-budget
# enforcement.
LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth(
request=request,
request_data=request_data,
user_api_key_dict=user_api_key_auth_obj,
)

_ = await common_checks(
request=request,
request_body=request_data,
Expand Down
68 changes: 68 additions & 0 deletions litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
TeamCallbackMetadata,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.callback_utils import (
get_metadata_variable_name_from_kwargs,
)
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers

# Cache special headers as a frozenset for O(1) lookup performance
Expand Down Expand Up @@ -1187,6 +1190,71 @@ def add_request_tag_to_metadata(

return tags

@staticmethod
def apply_client_tag_policy_pre_auth(
request: Request,
request_data: dict,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""
Merge ``x-litellm-tags`` header tags into ``request_data`` BEFORE
auth budget gates run, so ``_tag_max_budget_check`` (which only
inspects ``request_data``) sees them. Without this, header-tagged
requests silently bypass per-tag budget enforcement.

Why: ``add_litellm_data_to_request`` runs the equivalent merge
post-auth, after ``_tag_max_budget_check`` has already executed.
Header-supplied tags merged there are invisible to that check.
Running the merge here closes that gap; the post-auth merge in
``add_litellm_data_to_request`` remains as defense-in-depth.

How to apply: invoked from the auth chain just before
``common_checks``. Mutates ``request_data`` in place; idempotent
when followed by ``add_litellm_data_to_request``.
"""
# No allow_client_tags opt-in: caller-supplied tags always flow
# into metadata.tags (see add_litellm_data_to_request). The pre-auth
# merge mirrors that so _tag_max_budget_check sees the same tags.
headers = _safe_get_request_headers(request=request)
raw_header_tags = headers.get("x-litellm-tags")
if not raw_header_tags:
return

if isinstance(raw_header_tags, str):
header_tags: List[str] = [
t.strip() for t in raw_header_tags.split(",") if t.strip()
]
elif isinstance(raw_header_tags, list):
header_tags = [t for t in raw_header_tags if isinstance(t, str) and t]
else:
return

if not header_tags:
return

# Match the metadata key that get_tags_from_request_body will read
# from (litellm_metadata vs metadata) so the merged tags are visible
# to _tag_max_budget_check.
_metadata_variable_name = get_metadata_variable_name_from_kwargs(request_data)
metadata = request_data.get(_metadata_variable_name)
# metadata can arrive as a JSON string (multipart/form-data, extra_body).
# Parse it so existing tags survive the merge — overwriting the string
# with {} would let a caller bypass _tag_max_budget_check on an
# over-budget body tag by also sending a within-budget header tag.
if isinstance(metadata, str):
parsed = safe_json_loads(metadata)
metadata = parsed if isinstance(parsed, dict) else {}
request_data[_metadata_variable_name] = metadata
elif not isinstance(metadata, dict):
metadata = {}

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: Tag-budget bypass with string metadata

get_tags_from_request_body() parses string metadata for budget checks, but this new pre-auth merge replaces a string metadata or litellm_metadata value with {} before that parser runs. An authenticated caller whose key allows client tags can send metadata='{"tags":["over-budget-tag"]}' plus any x-litellm-tags header, causing _tag_max_budget_check to see only the header tags and skip the over-budget metadata tag. Parse string metadata here, or preserve the existing value and merge into the parsed dict instead of overwriting it.

request_data[_metadata_variable_name] = metadata

existing_tags = metadata.get("tags")
metadata["tags"] = LiteLLMProxyRequestSetup._merge_tags(
request_tags=existing_tags if isinstance(existing_tags, list) else None,
tags_to_add=header_tags,
)


async def add_litellm_data_to_request( # noqa: PLR0915
data: dict,
Expand Down
Loading
Loading