fix(azure_ai): authorize the targeted Search index, not any matching path segment

The Azure passthrough scanned every URL segment for one matching a registered
index, authorized against that, then forwarded the original path. A caller with
a grant on a managed index named e.g. "index" or "docs" could send
POST /azure_ai/indexes/{victim}/docs/index: the scan matched the trailing
segment and authorized on the caller's own index while Azure applied the batch
write to {victim} on the same Search service, enabling cross-index document
uploads or deletions.

Resolve the index positionally from the /indexes/{name} segment and require
that exact name to be the one authorized and credentialed, so the authorized
index and the physical target can never diverge. Add a pure helper plus
regression tests covering positional extraction and the route-level cross-index
attack.
This commit is contained in:
Noah Nistler 2026-07-17 14:26:14 -05:00 committed by mateo
parent 23f50e1f34
commit bdc80b11ac
2 changed files with 153 additions and 3 deletions

View file

@ -1234,6 +1234,22 @@ async def assemblyai_proxy_route(
return received_value
def get_azure_ai_search_index_from_endpoint(endpoint: str) -> str | None:
"""Return the index name in the ``/indexes/{name}`` position of an Azure AI
Search passthrough path, or ``None`` when the path targets no index.
Only the segment immediately after ``indexes`` is the operable target. Any
other segment (for example the trailing ``index`` in ``.../docs/index``) must
never be treated as the index, otherwise a caller authorized on one index
could have Azure apply the operation to a different index on the same service.
"""
segments: Final = endpoint.split("?", 1)[0].strip("/").split("/")
for position, segment in enumerate(segments):
if segment == "indexes" and position + 1 < len(segments):
return segments[position + 1] or None
return None
@router.api_route(
"/azure_ai/{endpoint:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
@ -1263,6 +1279,8 @@ async def azure_proxy_route(
"/"
) # azure model is in the url - e.g. https://{endpoint}/openai/deployments/{deployment-id}/completions?api-version=2024-10-21
search_index_name: Final = get_azure_ai_search_index_from_endpoint(endpoint)
if len(parts) > 1 and llm_router:
for part in parts:
# check if LLM MODEL
@ -1271,9 +1289,9 @@ async def azure_proxy_route(
)
# check if vector store index
is_vector_store_index = (
(litellm.vector_store_index_registry.is_vector_store_index(vector_store_index_name=part))
if litellm.vector_store_index_registry is not None
else False
part == search_index_name
and litellm.vector_store_index_registry is not None
and litellm.vector_store_index_registry.is_vector_store_index(vector_store_index_name=part)
)
if is_router_model:

View file

@ -19,9 +19,11 @@ import litellm
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
BaseOpenAIPassThroughHandler,
RouteChecks,
azure_proxy_route,
bedrock_llm_proxy_route,
create_pass_through_route,
cursor_proxy_route,
get_azure_ai_search_index_from_endpoint,
get_vertex_base_url,
llm_passthrough_factory_proxy_route,
milvus_proxy_route,
@ -3249,3 +3251,133 @@ def test_is_passthrough_request_streaming_tolerates_non_object_bodies(request_bo
)
assert is_passthrough_request_streaming(request_body) is expected
class TestGetAzureAISearchIndexFromEndpoint:
"""The operable index is only the segment right after ``indexes``.
A doc-write path ends in ``.../docs/index``; the trailing ``index`` must not
be mistaken for the target, otherwise a caller could be authorized on one
index while Azure applies the write to another.
"""
@pytest.mark.parametrize(
"endpoint, expected",
[
("indexes/my-index/docs/index", "my-index"),
("indexes/my-index/docs/search", "my-index"),
("indexes/my-index", "my-index"),
("indexes/my-index?api-version=2024-07-01", "my-index"),
("/indexes/my-index/docs/index", "my-index"),
("indexes/victim/docs/index", "victim"),
("openai/deployments/gpt-4o/chat/completions", None),
("indexes", None),
("indexes/", None),
],
)
def test_extracts_positional_index_only(self, endpoint, expected):
assert get_azure_ai_search_index_from_endpoint(endpoint) == expected
class TestAzureProxyRouteCrossIndexAuthorization:
"""Regression tests: the passthrough must authorize the index that the request
actually targets (the ``/indexes/{name}`` segment), never a different segment
that merely happens to match a managed index the caller can access.
"""
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.asyncio
async def test_authorizes_the_targeted_index(self):
index_object = MagicMock()
index_object.litellm_params.vector_store_name = "my-store"
vector_store = {"litellm_params": {"api_base": "https://svc.search.windows.net"}}
with (
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model",
return_value=False,
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.ProviderConfigManager.get_provider_vector_stores_config"
) as mock_get_config,
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_allowed_to_call_vector_store_endpoint"
) as mock_is_allowed,
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.assert_user_can_access_vector_store",
new=AsyncMock(),
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.BaseOpenAIPassThroughHandler._base_openai_pass_through_handler",
new=AsyncMock(return_value=Response()),
),
patch.object(litellm, "vector_store_index_registry") as mock_index_registry,
patch.object(litellm, "vector_store_registry") as mock_vector_registry,
):
mock_get_config.return_value.get_auth_credentials.return_value = {"headers": {"api-key": "k"}}
mock_index_registry.is_vector_store_index.side_effect = lambda vector_store_index_name: (
vector_store_index_name == "my-index"
)
mock_index_registry.get_vector_store_index_by_name.return_value = index_object
mock_vector_registry.get_litellm_managed_vector_store_from_registry_by_name.return_value = vector_store
await azure_proxy_route(
endpoint="indexes/my-index/docs/index",
request=self._request("POST", "/azure_ai/indexes/my-index/docs/index"),
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
)
mock_is_allowed.assert_called_once()
assert mock_is_allowed.call_args.kwargs["index_name"] == "my-index"
mock_index_registry.get_vector_store_index_by_name.assert_called_once_with(
vector_store_index_name="my-index"
)
@pytest.mark.asyncio
async def test_trailing_index_segment_does_not_authorize_a_different_index(self):
with (
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_passthrough_request_using_router_model",
return_value=False,
),
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_allowed_to_call_vector_store_endpoint"
) as mock_is_allowed,
patch(
"litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.get_secret_str",
return_value="https://azure-openai.example.com",
),
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,
patch.object(litellm, "vector_store_index_registry") as mock_index_registry,
):
mock_index_registry.is_vector_store_index.side_effect = lambda vector_store_index_name: (
vector_store_index_name == "index"
)
await azure_proxy_route(
endpoint="indexes/victim/docs/index",
request=self._request("POST", "/azure_ai/indexes/victim/docs/index"),
fastapi_response=MagicMock(spec=Response),
user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
)
mock_is_allowed.assert_not_called()
mock_handler.assert_awaited_once()
assert mock_handler.await_args.kwargs["custom_llm_provider"] == litellm.LlmProviders.AZURE