Skip to content

feat(proxy): authorize caller-supplied tags against key/team metadata.tags - #27771

Closed
yuneng-berri wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_/vibrant-bose-d1a024
Closed

feat(proxy): authorize caller-supplied tags against key/team metadata.tags#27771
yuneng-berri wants to merge 2 commits into
litellm_internal_stagingfrom
litellm_/vibrant-bose-d1a024

Conversation

@yuneng-berri

@yuneng-berri yuneng-berri commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores tag-based routing and tag-based spend attribution for caller-supplied tags (x-litellm-tags header, body tags, metadata.tags), then gates the privileged paths so unauthorized callers cannot reach tag-routed deployments or pollute tag-budget buckets owned by other callers.

Two commits, designed to read in order:

  1. fix(proxy): always merge caller-supplied tags into request metadata — removes the allow_client_tags strip + merge gate added in fix(proxy): read guardrail config from admin metadata, fix tag routing consistency #25905. Caller tags flow unconditionally into request metadata. Documented routing and spend-attribution paths work again.
  2. feat(proxy): authorize caller-supplied tags against key/team metadata.tags — adds an authorization gate at the two privileged consumers (tag-based routing and tag-budget enforcement). Reuses the existing metadata.tags field as the caller's authorized set, with fnmatch glob support.

How the authorization gate works

A caller-supplied tag is honored for tag-based routing and tag-budget enforcement only when one of:

  • the tag is not privileged — no deployment uses it (litellm_params.tags), no budget is attached to it (LiteLLM_TagTable.budget_id); OR
  • one of key.metadata.tags / team_metadata.tags carries a fnmatch glob pattern that matches the tag.

The privileged-tag set is auto-detected from existing router state and tag-table rows. It is cached in memory with a 30-second TTL — zero per-request DB hits on the hot path.

Examples

Key metadata Caller sends Outcome (privileged tag) Outcome (non-privileged tag)
{} x-litellm-tags: premium dropped (no grant) passes through
{"tags": ["premium"]} x-litellm-tags: premium honored — routes to premium deployment passes through
{"tags": ["tenant:*"]} x-litellm-tags: tenant:acme honored via glob passes through
{"tags": ["tenant:*"]} x-litellm-tags: premium dropped (no matching glob) n/a

What changed

File Change
litellm/proxy/litellm_pre_call_utils.py Delete the allow_client_tags strip block + the gated header merge
litellm/proxy/auth/tag_authorization.py Newcaller_authorized_for_tag, filter_authorized_tags, in-memory privileged-tag cache
litellm/router_strategy/tag_based_routing.py Filter request_tags through the authorization gate before per-deployment matching
litellm/proxy/auth/auth_checks.py Filter tags in _tag_max_budget_check before tag-budget batch lookup
tests/test_litellm/proxy/auth/test_tag_authorization.py New — 18 unit tests covering exact-match, glob, multi-source, defensive handling
tests/test_litellm/proxy/test_litellm_pre_call_utils.py Drop 3 dead-path tests, clean fixtures (from commit 1)
tests/proxy_unit_tests/test_proxy_utils.py Fixture cleanup (from commit 1)

Migration

No schema change. No new config field.

For customers using the existing tag features:

Customer setup Migration
Uses tags only for spend attribution / analytics None — non-privileged tags pass through
Uses tag-routing or tag-budgets Set metadata.tags = ["pattern"] on keys that should claim privileged tags; one /key/update per key

Hot-path cost

  • Per-request: one frozenset lookup per caller tag + a handful of fnmatch.fnmatchcase calls
  • Cache rebuild: at most once per 30 seconds; reads llm_router.model_list (in-memory) + one indexed scan of LiteLLM_TagTable
  • Per-request DB hits: zero

Test plan

End-to-end verification against a running proxy with two tag-routed deployments (["premium"], ["tenant:acme"]) and one default deployment:

  • K_NO (no metadata.tags) sends x-litellm-tags: premium → falls back to default (was reaching premium before)
  • K_NO sends x-litellm-tags: tenant:acme → falls back to default (was reaching acme before)
  • K_NO sends x-litellm-tags: workflow:report-gen → non-privileged, recorded in spend log request_tags
  • K_PREMIUM (metadata.tags=["premium"]) sends x-litellm-tags: premium → routes to premium deployment
  • K_TENANT (metadata.tags=["tenant:*"]) sends x-litellm-tags: tenant:acme → glob authorizes → routes to acme
  • K_NO sends no tags → 200 + default model response

Pytest:

  • tests/test_litellm/proxy/auth/test_tag_authorization.py — 18 passed
  • tests/test_litellm/router_strategy/test_router_tag_routing.py — 21 passed
  • tests/test_litellm/router_strategy/test_router_tag_regex_routing.py — 6 passed
  • tests/test_litellm/proxy/auth/test_auth_checks.py — 116 passed
  • tests/test_litellm/proxy/test_litellm_pre_call_utils.py — 125 passed

@greptile-apps

greptile-apps Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR removes the allow_client_tags opt-in gate from add_litellm_data_to_request, making caller-supplied tags from the x-litellm-tags header, data[\"tags\"], and metadata.tags flow unconditionally into routing metadata and spend attribution. The motivation is that the gate was silently breaking documented tag-based routing and spend-log features.

  • The entire tag-stripping block (~40 lines) and its associated conditional header-merge guard are deleted from litellm_pre_call_utils.py, with no replacement flag to preserve the strip behavior for operators who relied on it.
  • Six tests are removed or simplified: two explicit regression guards against unprivileged tag injection are deleted rather than updated, and four tests that required allow_client_tags: True have that fixture line stripped with assertions otherwise unchanged.

Confidence Score: 3/5

The change unconditionally opens tag injection to all callers, removing the only operator-side control over which keys/teams can supply routing and spend-attribution tags.

The core change deletes a security gate that had explicit documentation of its attack vectors with no replacement mechanism for operators who want to preserve the restricted behavior. Two regression tests that directly guarded those scenarios are also deleted rather than updated.

litellm/proxy/litellm_pre_call_utils.py and tests/test_litellm/proxy/test_litellm_pre_call_utils.py both need a second look — the former for the missing operator flag, the latter for the deleted regression guards.

Security Review

  • Tag-based routing bypass: Without the allow_client_tags gate, any caller can inject tags via x-litellm-tags header or request body to steer requests to tag-restricted deployments.
  • Spend misattribution: Caller-supplied tags now always reach metadata.tags and flow into /spend/tags aggregations, allowing a caller to attribute their spend to arbitrary tag buckets.
  • Removed operator control: The allow_client_pricing_override gate (still present) shows the expected pattern; the tag gate was removed with no equivalent flag for operators who need the strip behavior.

Important Files Changed

Filename Overview
litellm/proxy/litellm_pre_call_utils.py Removes the allow_client_tags gate entirely, making all caller-supplied tags unconditionally accepted into routing and spend metadata; a stale comment still references the removed "tags without opt-in" concept.
tests/test_litellm/proxy/test_litellm_pre_call_utils.py Deletes two explicit tag-injection regression guards and two allow_client_tags=True opt-in tests rather than updating them to document the new unconditional behavior.
tests/proxy_unit_tests/test_proxy_utils.py Removes allow_client_tags: True fixture lines from two spend-logs tests; union/merge assertions are unchanged and still pass.

Comments Outside Diff (1)

  1. litellm/proxy/litellm_pre_call_utils.py, line 1451-1455 (link)

    P2 Stale comment still references "tags without opt-in" but the opt-in mechanism has been removed in this PR.

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment on lines 1438 to 1442
_strip_client_pricing_overrides(data)

# Strip caller-supplied routing/budget tags unless the admin has opted
# this key or team in via metadata.allow_client_tags=True. Tags drive
# tag-based routing and tag budget attribution — accepting them from
# untrusted callers lets an attacker reach restricted deployments or
# misattribute spend to a victim team's tag.
_admin_allow_client_tags = False
for _admin_meta in (
user_api_key_dict.metadata,
user_api_key_dict.team_metadata,
):
if (
isinstance(_admin_meta, dict)
and _admin_meta.get("allow_client_tags") is True
):
_admin_allow_client_tags = True
break
if not _admin_allow_client_tags:
_stripped_from: List[str] = []
for _meta_key in ("metadata", "litellm_metadata"):
_user_meta = data.get(_meta_key)
if isinstance(_user_meta, dict) and "tags" in _user_meta:
_user_meta.pop("tags", None)
_stripped_from.append(_meta_key)
# Also strip the root-level `tags` field. get_tags_from_request_body
# reads request_body["tags"] directly and feeds it to the policy
# engine, so leaving it in place here would let the strip-in-metadata
# above be trivially bypassed by moving the tags to the body root.
if "tags" in data:
data.pop("tags", None)
_stripped_from.append("tags (root)")
if _stripped_from:
verbose_proxy_logger.warning(
"Stripped caller-supplied tags from %s: this key/team does "
"not have `allow_client_tags: true` in its metadata. Set it "
"to opt into client-supplied routing/budget tags.",
", ".join(_stripped_from),
)

# Fill in the proxy_server_request body snapshot now that metadata has
# been parsed and stripped. Consumers (standard_logging_payload, lago,
# been parsed. Consumers (standard_logging_payload, lago,
# spend_tracking_utils, streaming_iterator) read `body` to audit the

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 security Unconditional tag acceptance removes security control without a migration path

The removed allow_client_tags gate was the only mechanism preventing callers from injecting tags to influence routing decisions or misattribute spend. With this change, any unauthenticated or low-privilege caller can supply x-litellm-tags: premium-tier or metadata.tags to reach tag-gated deployments and attribute spend to arbitrary tag buckets in /spend/tags aggregations. Custom rule b48b7341 requires that backwards-incompatible removals of behavior preserve a user-controlled flag for the old path. The prior allow_client_pricing_override gate (still present, a few lines above) demonstrates the expected pattern — consider keeping allow_client_tags as a per-key/team flag and defaulting it to true instead of removing it entirely, so operators who relied on the strip behavior can opt back in.

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

@@ -874,101 +874,8 @@ async def test_add_litellm_data_to_request_allows_redaction_opt_out_with_admin_o

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 Deletion of tag-injection regression guards removes future protection

test_add_litellm_data_to_request_ignores_x_litellm_tags_header_without_permission and test_add_litellm_data_to_request_ignores_root_level_tags_without_permission were explicit regression tests for the path where an unprivileged caller injects tags via header or root body. Even if the behavior they test is intentionally being removed, deleting these rather than updating them to reflect the new expected behavior (e.g., asserting tags now flow through unconditionally) reduces the safety net for anyone who re-introduces the gate in the future.

Rule Used: What: Flag any modifications to existing tests and... (source)

@codecov

codecov Bot commented May 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 19 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/auth/tag_authorization.py 73.13% 18 Missing ⚠️
litellm/proxy/auth/auth_checks.py 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

tags = LiteLLMProxyRequestSetup.add_request_tag_to_metadata(
llm_router=llm_router,
headers=_headers,
data=data,
)

if tags is not None and _admin_allow_client_tags:
if tags is not None:

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.

High: Client-controlled routing tags

tags here comes directly from the request header or root body, and this PR also removed the earlier strip of metadata.tags / litellm_metadata.tags. Since router tag filtering uses metadata["tags"] to select deployments, a normal API caller can send a restricted deployment or team tag and have the request routed and accounted as if that tag was assigned by the key/team; keep caller-supplied tags behind an explicit admin opt-in and strip all tag sources by default.

@veria-ai

veria-ai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

High: Unauthorized tag spend attribution

This PR adds a privileged-tag filter for routing and tag-budget checks, but the filtered tags are not written back to request metadata. Downstream spend logging and spend counters consume the original caller-supplied metadata tags, so a normal API caller can charge spend to another tag bucket.


Status: 1 new · 2 open
Risk: 7/10

Caller-supplied tags (`x-litellm-tags` header, body `tags`, `metadata.tags`)
were silently dropped unless the key/team had
`metadata.allow_client_tags: true` set. Restore the documented behavior:
tags from the request always flow into `metadata.tags` and union with any
admin-configured static tags from key/team/project metadata.

Removes the `allow_client_tags` opt-in flag from the pre-call pipeline.
The flag was only ever read here; it has no schema or endpoint footprint,
so leftover values in existing key metadata are inert.

Test cleanup mirrors the simplification: drop the three tests that
verified the strip-when-not-opted-in path, drop the `allow_client_tags`
fixture lines from the merge/union tests.
….tags

A caller-supplied tag is honored for tag-based routing and tag-budget
enforcement only when one of:
  - the tag is not "privileged" (no deployment uses it, no budget
    attached), or
  - one of key.metadata.tags / team_metadata.tags carries a fnmatch glob
    that matches the tag.

The privileged-tag set is auto-detected from existing router state and
LiteLLM_TagTable rows with a budget; cached in memory with a 30s TTL so
the hot path stays I/O-free. Reuses the metadata.tags field already used
for admin-injected static tags - no schema change, no new config.

Restores the per-tenant SaaS routing pattern via glob (e.g. a gateway key
with metadata.tags=["tenant:*"] can claim "tenant:acme") while preventing
unauthorized callers from reaching tag-gated deployments or polluting
tag-budget buckets that belong to other callers.
@yuneng-berri
yuneng-berri force-pushed the litellm_/vibrant-bose-d1a024 branch from 5d105a5 to 6b3880f Compare May 12, 2026 21:56
@yuneng-berri yuneng-berri changed the title fix(proxy): always merge caller-supplied tags into request metadata feat(proxy): authorize caller-supplied tags against key/team metadata.tags May 12, 2026
@yuneng-berri

Copy link
Copy Markdown
Contributor Author

Superseded by a fresh PR — scope shifted from 'restore caller-tag flow' to 'restore + add authorization gate', and the reviewer comments here were about the earlier scope. Re-opening with the combined two-commit shape for a clean review pass.

# privileged tag. Non-privileged tags pass through unchanged.
if request_tags:
await ensure_fresh_privileged_tags()
request_tags = filter_authorized_tags(

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.

High: Unauthorized tag spend attribution

request_tags is filtered only for this router decision, but metadata["tags"] still contains the original caller-supplied tags. The cost callback later reads metadata["tags"] for request_tags and increments spend:tag:<tag>, so an API caller can include a victim budget tag and have their spend counted against that tag even though routing and the pre-call budget check ignored it. Filter the metadata tags in a central pre-call path, or write the authorized tag list back before any spend logging/counter code can read it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant