From 140628063ce7737421823dc3a166e3c212d07208 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:39:39 +0000 Subject: [PATCH 1/4] fix(team): gate /team/{id}/callback endpoints behind _verify_team_access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three endpoints in ``team_callback_endpoints.py`` accept a ``team_id`` from the URL but never check whether the authenticated caller can manage that team: * ``POST /team/{team_id}/callback`` — write Langfuse / Langsmith / GCS credentials to any team * ``POST /team/{team_id}/disable_logging`` — silence audit logging for any team * ``GET /team/{team_id}/callback`` — read back another team's stored third-party API credentials Each handler now runs the existing ``_verify_team_access`` helper (proxy-admin / org-admin / team-admin hierarchy already used by sibling endpoints in ``team_endpoints.py``) on the resolved team row before the read or write. Tests: - ``test_add_team_callbacks_rejects_unauthorized_caller`` — internal user not on the team gets 403; DB write never happens. - ``test_disable_team_logging_rejects_unauthorized_caller`` — same. - ``test_get_team_callbacks_rejects_unauthorized_caller`` — same on the read path; victim team's callback data stays inaccessible. - ``test_proxy_admin_can_add_team_callbacks`` — proxy admin still passes through to the DB write (sanity that the guard didn't over-rotate). - ``test_team_admin_of_target_team_can_add_callbacks`` — team admin of the target team still passes through. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../team_callback_endpoints.py | 27 +++ .../test_team_callback_endpoints.py | 211 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 4eec7c6b7c0..f28db70ef5d 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -13,12 +13,14 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from litellm._logging import verbose_proxy_logger from litellm.proxy._types import ( AddTeamCallback, + LiteLLM_TeamTable, ProxyErrorTypes, ProxyException, TeamCallbackMetadata, UserAPIKeyAuth, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.team_endpoints import _verify_team_access from litellm.proxy.management_helpers.utils import management_endpoint_wrapper router = APIRouter() @@ -100,6 +102,15 @@ async def add_team_callbacks( }, ) + # IDOR guard: only proxy admins / org admins / team admins of THIS + # team may write callback credentials. Without this, any + # authenticated key holder could overwrite another team's logging + # config (and read back the credentials they wrote). + await _verify_team_access( + team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()), + user_api_key_dict=user_api_key_dict, + ) + # store team callback settings in metadata team_metadata = _existing_team.metadata team_callback_settings: List[dict] = team_metadata.get( @@ -196,6 +207,14 @@ async def disable_team_logging( detail={"error": f"Team id = {team_id} does not exist."}, ) + # IDOR guard: only proxy admins / org admins / team admins of THIS + # team may disable its logging — otherwise any authenticated key + # holder can silence audit logging for any team. + await _verify_team_access( + team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()), + user_api_key_dict=user_api_key_dict, + ) + # Update team metadata to disable logging team_metadata = _existing_team.metadata team_callback_settings = team_metadata.get("callback_settings", {}) @@ -305,6 +324,14 @@ async def get_team_callbacks( detail={"error": f"Team id = {team_id} does not exist."}, ) + # IDOR guard: callback metadata holds third-party API credentials + # (Langfuse / Langsmith / GCS). Only proxy admins / org admins / + # team admins of THIS team may read them. + await _verify_team_access( + team_obj=LiteLLM_TeamTable(**_existing_team.model_dump()), + user_api_key_dict=user_api_key_dict, + ) + # Retrieve team callback settings from metadata team_metadata = _existing_team.metadata team_callback_settings = team_metadata.get("callback_settings", {}) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py new file mode 100644 index 00000000000..745c0583bbd --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -0,0 +1,211 @@ +""" +Regression tests for the IDOR fix on team callback endpoints +(GHSA-xxv2-fprq-9x93). + +The three endpoints below previously authenticated the caller but never +checked whether the caller could manage the target team — any +authenticated key holder could write callback credentials to any team, +disable any team's logging, or read back another team's stored +third-party API credentials (Langfuse / Langsmith / GCS). + +The fix routes each handler through ``_verify_team_access``, which +enforces the proxy-admin / org-admin / team-admin hierarchy used by +sibling endpoints in ``team_endpoints.py``. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from fastapi import HTTPException, Request + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy._types import ( + AddTeamCallback, + LitellmUserRoles, + Member, + UserAPIKeyAuth, +) +from litellm.proxy.management_endpoints.team_callback_endpoints import ( + add_team_callbacks, + disable_team_logging, + get_team_callbacks, +) + + +def _other_team_existing_row(): + """Return a mock team row owned by someone other than the test caller.""" + row = MagicMock() + row.model_dump.return_value = { + "team_id": "team-victim", + "team_alias": "victim-team", + "members_with_roles": [ + {"role": "admin", "user_id": "victim_admin"}, + ], + "organization_id": "org-victim", + } + row.metadata = {} + return row + + +@pytest.fixture +def unauthorized_caller(): + return UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="random_authenticated_user", + api_key="sk-random", + ) + + +@pytest.fixture +def patched_prisma(): + """ + Patch the proxy_server.prisma_client used inside each handler with a + mock that returns a victim-team row from get_data(). + """ + with ( + patch( + "litellm.proxy.proxy_server.prisma_client", + ) as mock_client, + patch( + "litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team", + new_callable=AsyncMock, + return_value=False, + ), + ): + mock_client.get_data = AsyncMock(return_value=_other_team_existing_row()) + mock_client.db.litellm_teamtable.update = AsyncMock() + yield mock_client + + +@pytest.mark.asyncio +async def test_add_team_callbacks_rejects_unauthorized_caller( + patched_prisma, unauthorized_caller +): + data = AddTeamCallback( + callback_name="langfuse", + callback_type="success", + callback_vars={ + "langfuse_public_key": "pk-attacker", + "langfuse_secret_key": "sk-attacker", + }, + ) + with pytest.raises(HTTPException) as exc: + await add_team_callbacks( + data=data, + http_request=Mock(spec=Request), + team_id="team-victim", + user_api_key_dict=unauthorized_caller, + ) + assert exc.value.status_code == 403 + # The unauthorized caller must NOT have written to the victim team. + patched_prisma.db.litellm_teamtable.update.assert_not_called() + + +@pytest.mark.asyncio +async def test_disable_team_logging_rejects_unauthorized_caller( + patched_prisma, unauthorized_caller +): + # The endpoint catches HTTPException and re-wraps it as ProxyException + # with the original status code preserved. + from litellm.proxy._types import ProxyException + + with pytest.raises((HTTPException, ProxyException)) as exc: + await disable_team_logging( + http_request=Mock(spec=Request), + team_id="team-victim", + user_api_key_dict=unauthorized_caller, + ) + code = getattr(exc.value, "status_code", None) or getattr(exc.value, "code", None) + assert int(code) == 403 + patched_prisma.db.litellm_teamtable.update.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_team_callbacks_rejects_unauthorized_caller( + patched_prisma, unauthorized_caller +): + # The endpoint catches generic Exception and re-wraps as ProxyException; + # an HTTPException raised by the access guard surfaces as a 403 + # ProxyException — both shapes are acceptable failure modes, what + # matters is that the caller does NOT receive the team's callback data. + from litellm.proxy._types import ProxyException + + with pytest.raises((HTTPException, ProxyException)) as exc: + await get_team_callbacks( + http_request=Mock(spec=Request), + team_id="team-victim", + user_api_key_dict=unauthorized_caller, + ) + code = getattr(exc.value, "status_code", None) or getattr(exc.value, "code", None) + assert int(code) == 403 + + +@pytest.mark.asyncio +async def test_proxy_admin_can_add_team_callbacks(patched_prisma): + """ + A proxy admin should pass the access guard and reach the DB write. + Sanity check that the guard didn't over-rotate. + """ + proxy_admin = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="admin", + api_key="sk-admin", + ) + data = AddTeamCallback( + callback_name="langfuse", + callback_type="success", + callback_vars={ + "langfuse_public_key": "pk-admin", + "langfuse_secret_key": "sk-admin", + }, + ) + await add_team_callbacks( + data=data, + http_request=Mock(spec=Request), + team_id="team-victim", + user_api_key_dict=proxy_admin, + ) + patched_prisma.db.litellm_teamtable.update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_team_admin_of_target_team_can_add_callbacks(patched_prisma): + """ + A team admin OF THE TARGET team should pass the access guard. + """ + # Override the victim row so the caller IS the team admin. + row = MagicMock() + row.model_dump.return_value = { + "team_id": "team-victim", + "team_alias": "victim-team", + "members_with_roles": [ + {"role": "admin", "user_id": "team_admin_user"}, + ], + "organization_id": "org-victim", + } + row.metadata = {} + patched_prisma.get_data = AsyncMock(return_value=row) + + team_admin = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="team_admin_user", + api_key="sk-team-admin", + ) + data = AddTeamCallback( + callback_name="langfuse", + callback_type="success", + callback_vars={ + "langfuse_public_key": "pk-team", + "langfuse_secret_key": "sk-team", + }, + ) + await add_team_callbacks( + data=data, + http_request=Mock(spec=Request), + team_id="team-victim", + user_api_key_dict=team_admin, + ) + patched_prisma.db.litellm_teamtable.update.assert_awaited_once() From 578846e57d6822907b16ed2f96bccbc77f080d04 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:56:04 +0000 Subject: [PATCH 2/4] fix(team): don't log legitimate 403s at error level on /callback endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged that ``disable_team_logging`` and ``get_team_callbacks`` re-wrap any ``HTTPException`` (including the 403 from the access guard) through a catch-all that logs at ``.error()`` before re-raising — so every legitimate access-denied response would pollute alerting dashboards as a "server error". Add explicit ``except HTTPException: raise`` and ``except ProxyException: raise`` branches before the catch-all (matching the pattern already used in ``add_team_callbacks``). 4xx now propagates quietly; only genuinely unexpected exceptions still hit the error-level log. Tests assert ``HTTPException`` is now the surfaced shape (instead of the previous ``ProxyException`` re-wrap). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../team_callback_endpoints.py | 23 +++++++++++-------- .../test_team_callback_endpoints.py | 23 ++++++------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index f28db70ef5d..934824dd89f 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -251,20 +251,18 @@ async def disable_team_logging( }, } + except HTTPException: + # Legitimate 4xx (e.g. 403 from the access guard, 404 for an + # unknown team). Re-raise without the error-level log noise that + # the catch-all branch below would produce. + raise + except ProxyException: + raise except Exception as e: verbose_proxy_logger.error( f"litellm.proxy.proxy_server.disable_team_logging(): Exception occurred - {str(e)}" ) verbose_proxy_logger.debug(traceback.format_exc()) - if isinstance(e, HTTPException): - raise ProxyException( - message=getattr(e, "detail", f"Internal Server Error({str(e)})"), - type=ProxyErrorTypes.internal_server_error.value, - param=getattr(e, "param", "None"), - code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR), - ) - elif isinstance(e, ProxyException): - raise e raise ProxyException( message="Internal Server Error, " + str(e), type=ProxyErrorTypes.internal_server_error.value, @@ -349,6 +347,13 @@ async def get_team_callbacks( }, } + except HTTPException: + # Legitimate 4xx (e.g. 403 from the access guard) — re-raise + # without the error-level log noise that the catch-all below + # would produce. + raise + except ProxyException: + raise except Exception as e: verbose_proxy_logger.error( "litellm.proxy.proxy_server.get_team_callbacks(): Exception occurred - {}".format( diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py index 745c0583bbd..e26820ea9cd 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -108,18 +108,16 @@ async def test_add_team_callbacks_rejects_unauthorized_caller( async def test_disable_team_logging_rejects_unauthorized_caller( patched_prisma, unauthorized_caller ): - # The endpoint catches HTTPException and re-wraps it as ProxyException - # with the original status code preserved. - from litellm.proxy._types import ProxyException - - with pytest.raises((HTTPException, ProxyException)) as exc: + # The HTTPException from the access guard now propagates directly + # — the catch-all that previously re-wrapped (and logged at error + # level) was narrowed so legitimate 4xx don't pollute alerting. + with pytest.raises(HTTPException) as exc: await disable_team_logging( http_request=Mock(spec=Request), team_id="team-victim", user_api_key_dict=unauthorized_caller, ) - code = getattr(exc.value, "status_code", None) or getattr(exc.value, "code", None) - assert int(code) == 403 + assert exc.value.status_code == 403 patched_prisma.db.litellm_teamtable.update.assert_not_called() @@ -127,20 +125,13 @@ async def test_disable_team_logging_rejects_unauthorized_caller( async def test_get_team_callbacks_rejects_unauthorized_caller( patched_prisma, unauthorized_caller ): - # The endpoint catches generic Exception and re-wraps as ProxyException; - # an HTTPException raised by the access guard surfaces as a 403 - # ProxyException — both shapes are acceptable failure modes, what - # matters is that the caller does NOT receive the team's callback data. - from litellm.proxy._types import ProxyException - - with pytest.raises((HTTPException, ProxyException)) as exc: + with pytest.raises(HTTPException) as exc: await get_team_callbacks( http_request=Mock(spec=Request), team_id="team-victim", user_api_key_dict=unauthorized_caller, ) - code = getattr(exc.value, "status_code", None) or getattr(exc.value, "code", None) - assert int(code) == 403 + assert exc.value.status_code == 403 @pytest.mark.asyncio From a9bc5549b2616d06bb4a824940b941e0d91812c3 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:51:59 +0000 Subject: [PATCH 3/4] fix(team): also gate organization-scoped endpoints behind _verify_org_access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variant analysis on the team-callback IDOR (GHSA-xxv2-fprq-9x93) surfaced the same shape on organization-scoped endpoints. Each takes ``organization_id`` from the request body, looks up the org, and performs reads or writes — without checking whether the caller can manage that org. Affected endpoints: * ``PATCH /organization/update`` — any authenticated key holder could rewrite any org's metadata, budgets, and object permissions. * ``POST /organization/member_add`` — docstring promises "Only proxy_admin or org_admin allowed" but the code never enforced it; any caller could add members to any org. * ``POST /organization/member_update`` — only the ``modify-PROXY_ADMIN-target-only`` defense was in place; non-admin members in any org could be re-roled by any caller. * ``POST /organization/member_delete`` — no access check at all; any caller could remove any user from any org. Each handler now runs the existing ``_verify_org_access`` helper (proxy-admin / org-admin hierarchy already used by ``GET /organization/info`` and ``POST /organization/info``) before the read or write. Tests: - ``test_organization_member_add_rejects_unauthorized_caller`` — internal user not on the org gets 403; DB write never happens. - ``test_organization_member_update_rejects_unauthorized_caller`` — same. - ``test_organization_member_delete_rejects_unauthorized_caller`` — same. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../organization_endpoints.py | 38 +++++ .../test_organization_endpoints.py | 130 ++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 442fae2a4fa..79b9c459e4b 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -500,6 +500,15 @@ async def update_organization( if data.updated_by is None: data.updated_by = user_api_key_dict.user_id + # IDOR guard: only proxy admins / org admins of THIS org may update + # it. Without this, any authenticated key holder could rewrite + # another organization's metadata, budgets, and object permissions. + await _verify_org_access( + organization_id=data.organization_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + existing_organization_row = ( await prisma_client.db.litellm_organizationtable.find_unique( where={"organization_id": data.organization_id}, @@ -909,6 +918,16 @@ async def organization_member_add( if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) + # IDOR guard: docstring says "Only proxy_admin or org_admin of + # organization, allowed to access this endpoint" — but the code + # never enforced that. Any authenticated key holder could add + # members to any org. Now gated explicitly. + await _verify_org_access( + organization_id=data.organization_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + # Check if organization exists existing_organization_row = ( await prisma_client.db.litellm_organizationtable.find_unique( @@ -1018,6 +1037,16 @@ async def organization_member_update( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) + # IDOR guard: only proxy admins / org admins of THIS org may + # update member roles. The PROXY_ADMIN-target check below was + # the only access control; without this, any authenticated user + # could change any non-admin member's role in any org. + await _verify_org_access( + organization_id=data.organization_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + # Check if organization exists existing_organization_row = ( await prisma_client.db.litellm_organizationtable.find_unique( @@ -1179,6 +1208,15 @@ async def organization_member_delete( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) + # IDOR guard: only proxy admins / org admins of THIS org may + # delete members. Without this, any authenticated key holder + # could remove any user from any org. + await _verify_org_access( + organization_id=data.organization_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + if data.user_email is not None and data.user_id is None: existing_user_email_row = await find_member_if_email( data.user_email, prisma_client diff --git a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py index 4501cc76636..f4470e7e83d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -565,3 +565,133 @@ async def test_organization_info_includes_user_email(monkeypatch): membership = LiteLLM_OrganizationMembershipTable(**raw_membership) assert membership.user_email == "alice@example.com" + + +# Regression tests for IDOR fixes on org-scoped endpoints. Sibling cluster +# to GHSA-xxv2-fprq-9x93 (team callback IDOR): the same shape of "any +# authenticated key holder reaches an endpoint that takes an +# organization_id from the request body without an access guard." The +# fix routes ``update_organization``, ``organization_member_add``, +# ``organization_member_update``, and ``organization_member_delete`` +# through the existing ``_verify_org_access`` helper. + + +@pytest.fixture +def unauthorized_caller(): + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + return UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="random_authenticated_user", + api_key="sk-random", + ) + + +@pytest.fixture +def patched_org_prisma(): + """Mock prisma so that find_unique returns a victim org and + get_user_object reports the caller has no org membership — so + _verify_org_access raises 403.""" + victim_row = MagicMock() + victim_row.organization_id = "org-victim" + victim_row.metadata = {} + victim_row.model_dump.return_value = {"organization_id": "org-victim"} + + caller_user = MagicMock() + caller_user.organization_memberships = [] # no admin role anywhere + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.management_endpoints.organization_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=caller_user, + ), + patch( + "litellm.proxy.proxy_server.user_api_key_cache", + ), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + ): + mock_prisma.db.litellm_organizationtable.find_unique = AsyncMock( + return_value=victim_row + ) + yield mock_prisma + + +@pytest.mark.asyncio +async def test_organization_member_add_rejects_unauthorized_caller( + patched_org_prisma, unauthorized_caller +): + # ``organization_member_add`` catches HTTPException in its + # catch-all and re-wraps as ProxyException with the original status + # code preserved. + from litellm.proxy._types import ( + OrganizationMemberAddRequest, + OrgMember, + ProxyException, + ) + from litellm.proxy.management_endpoints.organization_endpoints import ( + organization_member_add, + ) + from unittest.mock import Mock + + from fastapi import Request + + data = OrganizationMemberAddRequest( + organization_id="org-victim", + member=OrgMember(role="internal_user", user_id="attacker-user"), + ) + + with pytest.raises((HTTPException, ProxyException)) as exc: + await organization_member_add( + data=data, + http_request=Mock(spec=Request), + user_api_key_dict=unauthorized_caller, + ) + code = getattr(exc.value, "status_code", None) or getattr(exc.value, "code", None) + assert int(code) == 403 + + +@pytest.mark.asyncio +async def test_organization_member_update_rejects_unauthorized_caller( + patched_org_prisma, unauthorized_caller +): + from litellm.proxy._types import OrganizationMemberUpdateRequest + from litellm.proxy.management_endpoints.organization_endpoints import ( + organization_member_update, + ) + + data = OrganizationMemberUpdateRequest( + organization_id="org-victim", + user_id="some-other-user", + role="org_admin", + ) + + with pytest.raises(HTTPException) as exc: + await organization_member_update( + data=data, + user_api_key_dict=unauthorized_caller, + ) + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_organization_member_delete_rejects_unauthorized_caller( + patched_org_prisma, unauthorized_caller +): + from litellm.proxy._types import OrganizationMemberDeleteRequest + from litellm.proxy.management_endpoints.organization_endpoints import ( + organization_member_delete, + ) + + data = OrganizationMemberDeleteRequest( + organization_id="org-victim", + user_id="some-other-user", + ) + + with pytest.raises(HTTPException) as exc: + await organization_member_delete( + data=data, + user_api_key_dict=unauthorized_caller, + ) + assert exc.value.status_code == 403 From 6c386af9c772f6e024ed8b4636dfa3b57502bc41 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:00:33 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(team):=20mypy=20=E2=80=94=20guard=20org?= =?UTF-8?q?anization=5Fid=20None=20before=20=5Fverify=5Forg=5Faccess=20cal?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``LiteLLM_OrganizationTableUpdate.organization_id`` is typed ``Optional[str]`` to allow update payloads that don't change the id. ``_verify_org_access`` expects ``str``. Add an explicit None check that raises 400 before the access guard fires — previously this would have crashed at runtime on a malformed update payload. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../proxy/management_endpoints/organization_endpoints.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 79b9c459e4b..ee683f322a1 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -500,6 +500,12 @@ async def update_organization( if data.updated_by is None: data.updated_by = user_api_key_dict.user_id + if data.organization_id is None: + raise HTTPException( + status_code=400, + detail={"error": "organization_id is required"}, + ) + # IDOR guard: only proxy admins / org admins of THIS org may update # it. Without this, any authenticated key holder could rewrite # another organization's metadata, budgets, and object permissions.