fix(azure_ai): enforce admin-only index create on the passthrough route

POST /azure_ai/indexes carries no index name, so
get_azure_ai_search_index_from_endpoint returns None,
is_vector_store_index never matches any segment, and the request falls
through to the generic Azure passthrough on the proxy's own
AZURE_API_BASE and AZURE_API_KEY without ever reaching
is_allowed_to_call_vector_store_endpoint. A non-admin could therefore
create a Search index whenever AZURE_API_BASE points at the Search
service.

The earlier lifecycle commit made this look covered. Its test asserts
that POST /indexes?api-version=... is refused with "Only proxy admins can
create", but it calls the permission gate directly, and that gate is
exactly what the route skips for a path with no index name, so the guard
was verified in isolation while the route stayed open.

Gate the service-level create on the route itself, before the segment
loop, with assert_proxy_admin_for_vector_store_index_management. Scope it
to POST on a path whose last segment is indexes, mirroring the
endswith("/indexes") branch the lifecycle helper already uses, so the
managed-index paths and ordinary Azure OpenAI passthrough traffic are
untouched.

Add route-level tests: a non-admin is refused with the admin-only message
and never reaches the passthrough handler, an admin still creates, and the
new predicate is parametrized over the service-level, per-index, and
non-Search paths.
This commit is contained in:
Noah Nistler 2026-07-28 10:55:51 -05:00 committed by mateo
parent c1125f0abb
commit f8fccec108
2 changed files with 110 additions and 2 deletions

View file

@ -44,6 +44,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
)
from litellm.proxy.utils import is_known_model
from litellm.proxy.vector_store_endpoints.utils import (
assert_proxy_admin_for_vector_store_index_management,
assert_user_can_access_vector_store,
get_litellm_managed_vector_store,
is_allowed_to_call_vector_store_endpoint,
@ -1250,6 +1251,21 @@ def get_azure_ai_search_index_from_endpoint(endpoint: str) -> str | None:
return None
def is_azure_ai_search_service_level_index_create(method: str, endpoint: str) -> bool:
"""Return True for ``POST /indexes``, Azure AI Search's service-level index create.
No index name appears in that path, so ``get_azure_ai_search_index_from_endpoint``
yields None and the managed-index branch can never claim the request. Without an
explicit guard it reaches the generic Azure passthrough on the proxy's own
credential, so a non-admin could create an index whenever ``AZURE_API_BASE``
points at the Search service.
"""
if method != "POST":
return False
path: Final = endpoint.split("?", 1)[0].strip("/")
return path == "indexes" or path.endswith("/indexes")
@router.api_route(
"/azure_ai/{endpoint:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
@ -1275,6 +1291,9 @@ async def azure_proxy_route(
"""
from litellm.proxy.proxy_server import llm_router
if is_azure_ai_search_service_level_index_create(method=request.method, endpoint=endpoint):
assert_proxy_admin_for_vector_store_index_management(user_api_key_dict, operation="create")
parts: Final = endpoint.split(
"/"
) # azure model is in the url - e.g. https://{endpoint}/openai/deployments/{deployment-id}/completions?api-version=2024-10-21

View file

@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
from fastapi import Request, Response
from fastapi import HTTPException, Request, Response
from fastapi.testclient import TestClient
sys.path.insert(
@ -25,6 +25,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
cursor_proxy_route,
get_azure_ai_search_index_from_endpoint,
get_vertex_base_url,
is_azure_ai_search_service_level_index_create,
llm_passthrough_factory_proxy_route,
milvus_proxy_route,
mistral_proxy_route,
@ -33,7 +34,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
vertex_proxy_route,
vllm_proxy_route,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials
@ -3381,3 +3382,91 @@ class TestAzureProxyRouteCrossIndexAuthorization:
mock_is_allowed.assert_not_called()
mock_handler.assert_awaited_once()
assert mock_handler.await_args.kwargs["custom_llm_provider"] == litellm.LlmProviders.AZURE
class TestAzureProxyRouteServiceLevelIndexCreate:
"""``POST /indexes`` carries no index name, so the managed-index branch cannot
claim it and it would otherwise reach the generic Azure passthrough on the
proxy's own credential. The admin-only index management guard has to be
enforced on the route itself, not just on the permission gate the route skips.
"""
def _request(self, method: str, path: str) -> MagicMock:
request = MagicMock(spec=Request)
request.method = method
request.headers = {"content-type": "application/json"}
request.url = MagicMock()
request.url.path = path
return request
@pytest.mark.parametrize(
"method, endpoint, expected",
[
("POST", "indexes", True),
("POST", "indexes?api-version=2024-07-01", True),
("POST", "/indexes/", True),
("POST", "indexes/my-index", False),
("POST", "indexes/my-index/docs/index", False),
("GET", "indexes", False),
("POST", "openai/deployments/gpt-4o/chat/completions", False),
],
)
def test_recognizes_service_level_create(self, method, endpoint, expected):
assert is_azure_ai_search_service_level_index_create(method=method, endpoint=endpoint) is expected
@pytest.mark.asyncio
async def test_non_admin_cannot_create_an_index(self):
with (
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_secret_str",
return_value="https://svc.search.windows.net",
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.BaseOpenAIPassThroughHandler._base_openai_pass_through_handler",
new=AsyncMock(return_value=Response()),
) as mock_handler,
):
with pytest.raises(HTTPException) as exc_info:
await azure_proxy_route(
endpoint="indexes?api-version=2024-07-01",
request=self._request("POST", "/azure_ai/indexes"),
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=UserAPIKeyAuth(
token="sk-team-token",
user_role=LitellmUserRoles.INTERNAL_USER,
),
)
assert exc_info.value.status_code == 403
assert "Only proxy admins can create" in exc_info.value.detail
mock_handler.assert_not_awaited()
@pytest.mark.asyncio
async def test_admin_can_still_create_an_index(self):
with (
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_secret_str",
return_value="https://svc.search.windows.net",
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials",
return_value="azure-key",
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.BaseOpenAIPassThroughHandler._base_openai_pass_through_handler",
new=AsyncMock(return_value=Response()),
) as mock_handler,
):
await azure_proxy_route(
endpoint="indexes?api-version=2024-07-01",
request=self._request("POST", "/azure_ai/indexes"),
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=UserAPIKeyAuth(
token="sk-admin-token",
user_role=LitellmUserRoles.PROXY_ADMIN,
),
)
mock_handler.assert_awaited_once()