Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import re
import secrets
from datetime import datetime, timezone
from typing import Any, Iterator, List, Optional, Tuple, Union, cast
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, cast

import fastapi
from fastapi import HTTPException, Request, WebSocket, status
Expand Down Expand Up @@ -332,8 +332,22 @@ def _apply_budget_limits_to_end_user_params(
async def user_api_key_auth_websocket(websocket: WebSocket):
# Accept the WebSocket connection

scope_headers = list(websocket.scope.get("headers") or [])
request = Request(scope={"type": "http", "headers": scope_headers})
ws_scope = websocket.scope or {}
scope_headers = list(ws_scope.get("headers") or [])
# ``get_request_route`` falls back to ``request.url.path`` when
# ``scope["path"]`` is absent. On WebSockets that fallback reads
# ``websocket.url``, which Starlette reconstructs from the (poisonable)
# Host header. Carry the ASGI scope's path / root_path so the lookup
# never reaches the fallback.
synthetic_scope: Dict[str, Any] = {
"type": "http",
"headers": scope_headers,
"path": ws_scope.get("path", ""),
}
for key in ("root_path", "app_root_path"):
if key in ws_scope:
synthetic_scope[key] = ws_scope[key]
request = Request(scope=synthetic_scope)

request._url = websocket.url

Expand Down
30 changes: 30 additions & 0 deletions tests/proxy_unit_tests/test_user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,36 @@ async def test_user_api_key_auth_websocket():
)


@pytest.mark.asyncio
async def test_user_api_key_auth_websocket_carries_asgi_path():
"""
The synthetic Request must carry the ASGI scope's ``path`` so
``get_request_route`` returns the real WebSocket path, not a value
reconstructed from the (Host-poisonable) ``websocket.url``.
"""
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth_websocket

mock_websocket = MagicMock(spec=WebSocket)
mock_websocket.query_params = {"model": "some_model"}
mock_websocket.headers = {"authorization": "Bearer some_api_key"}
mock_websocket.scope = {
"type": "websocket",
"path": "/v1/realtime",
"root_path": "",
"headers": [(b"authorization", b"Bearer some_api_key")],
}
mock_websocket.url = URL(url="/v1/realtime")

with patch(
"litellm.proxy.auth.user_api_key_auth.user_api_key_auth", autospec=True
) as mock_user_api_key_auth:
await user_api_key_auth_websocket(mock_websocket)

request_arg = mock_user_api_key_auth.call_args.kwargs["request"]
assert request_arg.scope.get("path") == "/v1/realtime"
assert request_arg.scope.get("root_path") == ""


@pytest.mark.parametrize("enforce_rbac", [True, False])
@pytest.mark.asyncio
async def test_jwt_user_api_key_auth_builder_enforce_rbac(enforce_rbac, monkeypatch):
Expand Down
Loading