Skip to content

fix(proxy): close project hijacking and key org IDOR (VERIA-55) - #27011

Merged
yuneng-berri merged 1 commit into
BerriAI:litellm_yj_may1_2from
stuxf:fix/project-update-cross-team-hijack
May 2, 2026
Merged

fix(proxy): close project hijacking and key org IDOR (VERIA-55)#27011
yuneng-berri merged 1 commit into
BerriAI:litellm_yj_may1_2from
stuxf:fix/project-update-cross-team-hijack

Conversation

@stuxf

@stuxf stuxf commented May 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two related authorization gaps in management endpoints:

/project/update — cross-team project hijacking

_check_user_permission_for_project was being called with team_object=team_obj_for_checks, where team_obj_for_checks was fetched from the caller-supplied data.team_id. Any team admin could pass data.team_id=<team-they-admin> while addressing a project that lived under a different team, and the helper would happily check admin membership against the attacker's team — confused-deputy-style.

Fix:

  • Drop the team_object kwarg from the permission check so the helper falls through to its DB-fetch path against the project's existing team.
  • When the request also wants to reassign the project (data.team_id != existing_project.team_id), separately verify the caller is admin of the destination team. Without this, a team admin could shed projects into someone else's namespace.

/key/update — secondary IDOR on organization_id

The org-limit branch fetched the org by id and checked limits, but never checked that the caller actually belonged to the target org. Any user could update their key's organization_id to point at any org. Spend-tracking happens to read org from the team association, so the budget-exhaustion impact is mitigated, but the IDOR itself is real.

Fix: add _validate_caller_can_assign_key_org, called when data.organization_id is non-None and changes from the existing key's value. It mirrors the membership rule already enforced on the /key/list filter path (validate_key_list_check). Proxy admins skip the check.

Behavior changes

  • A team admin can no longer modify a project that belongs to a different team by passing data.team_id.
  • Reassigning a project to a new team now requires admin rights on both the existing and destination teams (proxy admin still bypasses).
  • A non-admin caller can no longer set their key's organization_id to an org they are not a member of.

Test plan

  • uv run pytest tests/test_litellm/proxy/management_endpoints/test_project_org_authz.py -q — 7 pass (3 project, 4 key/org)
  • uv run black --check on touched files

Type

🐛 Bug Fix
✅ Test

Two related authorization gaps in management endpoints:

1. `/project/update` evaluated permission against the team_id supplied in
   the request body. By passing `data.team_id` pointing at a team they
   admin, a caller could hijack any project — `_check_user_permission_for_project`
   was given the attacker's team_object and happily checked admin
   membership against that. Drop the team_object kwarg so the helper
   re-fetches the existing project's team. Also require admin rights on
   the destination team when reassigning a project across teams, so a
   team admin cannot shed projects into another team's namespace.

2. `/key/update` accepted any `organization_id` and only checked that
   the org existed before applying limits. A caller could thereby point
   their key at an arbitrary org. Add `_validate_caller_can_assign_key_org`
   which enforces the same membership rule already applied on the
   `/key/list` filter path (`validate_key_list_check`); proxy admins and
   no-change updates skip the check.

Tests cover both helpers in isolation: existing-team-admin allow,
unrelated-team admin deny, proxy-admin shortcut, org-member allow,
non-member deny, missing user_id deny, no-memberships deny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented May 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes two authorization gaps: a confused-deputy bug in /project/update (permission now checked against the project's current team rather than the caller-supplied data.team_id, with an additional check for the destination team on reassignment) and an IDOR in /key/update (non-admin callers can no longer set organization_id to an org they don't belong to via the new _validate_caller_can_assign_key_org guard). The fixes are logically sound and guarded by 7 new unit tests.

Confidence Score: 4/5

Safe to merge; both security fixes are logically correct with no regressions identified.

All findings are P2 (minor inefficiency and test-coverage gaps). The core logic of both fixes is correct: the project permission check uses the existing team ID, the reassignment double-check is properly gated, and the org membership validation mirrors the established pattern on the key/list path.

No files require special attention beyond the minor noted observations.

Important Files Changed

Filename Overview
enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py Fixes confused-deputy bug in update_project: permission check now uses existing_project.team_id instead of caller-supplied data.team_id; adds second permission check for destination team on reassignment. Minor: target_team_obj is fetched but not reused for the initial permission call in the no-reassignment branch.
litellm/proxy/management_endpoints/key_management_endpoints.py Adds _validate_caller_can_assign_key_org and calls it in _validate_update_key_data to block non-admins from pointing a key at an org they don't belong to. Logic is correct; new function uses a direct uncached DB query instead of the shared user-object helper.
tests/test_litellm/proxy/management_endpoints/test_project_org_authz.py New test file with 7 unit tests covering both helpers in isolation using mocks only (no real network calls). Covers the core allow/deny cases but lacks an integration-level test for the full update_project flow that proves the original confused-deputy path is blocked.

Reviews (1): Last reviewed commit: "fix(proxy): close project hijacking and ..." | Re-trigger Greptile

Comment on lines +595 to 606
target_team_id = data.team_id or existing_project.team_id
target_team_obj = None
if target_team_id is not None:
target_team_obj = await _validate_team_exists(
team_id=target_team_id, prisma_client=prisma_client
)

# Check if user has permission to update this project
has_permission = await _check_user_permission_for_project(
user_api_key_dict=user_api_key_dict,
team_id=existing_project.team_id,
prisma_client=prisma_client,
team_object=(
LiteLLM_TeamTable(**team_obj_for_checks.model_dump())
if team_obj_for_checks
else 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.

P2 Redundant DB fetch when team_id is unchanged

When data.team_id equals existing_project.team_id (the caller sends the same team, no reassignment), target_team_obj is already fetched via _validate_team_exists for exactly that team. The subsequent _check_user_permission_for_project call at line 602 omits team_object, so the helper issues another litellm_teamtable.find_unique for the same ID. Passing target_team_obj to the initial permission call when no reassignment is happening would eliminate this duplicate round-trip.

Note: when data.team_id != existing_project.team_id the extra fetch is intentional — target_team_obj is for the destination team, not the source — so the deduplication only applies to the no-reassignment branch.

Comment on lines +1186 to +1196
)

user_row = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id},
include={"organization_memberships": True},
)
memberships = (
getattr(user_row, "organization_memberships", None) if user_row else None
)
member_org_ids = {
membership.organization_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.

P2 Direct uncached DB query in management endpoint

_validate_caller_can_assign_key_org calls prisma_client.db.litellm_usertable.find_unique directly, bypassing any in-process cache. Each /key/update call that changes organization_id will pay a full round-trip for the user row. Other membership lookups in this codebase (e.g., validate_key_list_check) use get_user_object or a shared cache. Aligning with that pattern would avoid the extra latency and keep DB access consistent.

Comment on lines +40 to +58

# Project lives on team-A, caller is admin only of team-B.
prisma = _make_prisma_with_team(team_id="team-A", admins=["alice"])
caller = UserAPIKeyAuth(
user_id="bob",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)

has_perm = await _check_user_permission_for_project(
user_api_key_dict=caller,
team_id="team-A",
prisma_client=prisma,
)
assert has_perm is False
prisma.db.litellm_teamtable.find_unique.assert_awaited_once()


@pytest.mark.asyncio
async def test_project_perm_check_allows_team_admin_of_existing_team():

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 Tests cover the helper but not the integration fix

The unit tests verify _check_user_permission_for_project and _validate_caller_can_assign_key_org in isolation, which is useful. However, the core fix in update_project is that the function is now called with team_id=existing_project.team_id instead of team_id=data.team_id. A test that drives the full update_project code path — mocking the DB to return a project belonging to team-A while the attacker sends data.team_id="team-B" where they are an admin — would directly prove the confused-deputy attack is blocked rather than inferring it from the helper's behaviour.

@yuneng-berri
yuneng-berri changed the base branch from litellm_internal_staging to litellm_yj_may1_2 May 2, 2026 01:01
@yuneng-berri
yuneng-berri merged commit e78d87e into BerriAI:litellm_yj_may1_2 May 2, 2026
43 checks passed
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…team-hijack

fix(proxy): close project hijacking and key org IDOR (VERIA-55)
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.

2 participants