mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
23f50e1f34
commit
bdc80b11ac
2 changed files with 153 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue