Skip to content

Commit bf95c60

Browse files
committed
Fix passthrough route auth checks
1 parent 980deee commit bf95c60

6 files changed

Lines changed: 99 additions & 1 deletion

File tree

litellm/proxy/auth/handle_jwt.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1120,10 +1120,14 @@ async def find_team_with_model_access(
11201120
user_api_key_cache: UserApiKeyCache,
11211121
parent_otel_span: Optional[Span],
11221122
proxy_logging_obj: ProxyLogging,
1123+
request_method: Optional[str] = None,
11231124
) -> Tuple[Optional[str], Optional[LiteLLM_TeamTable]]:
11241125
"""Find first team with access to the requested model"""
11251126
from litellm.proxy.proxy_server import llm_router
11261127

1128+
normalized_request_method = (
1129+
request_method.upper() if isinstance(request_method, str) else None
1130+
)
11271131
denied_auth_enforced_pass_through_route = False
11281132

11291133
if not team_ids:
@@ -1163,7 +1167,8 @@ async def find_team_with_model_access(
11631167
if (
11641168
is_allowed
11651169
and RouteChecks.is_auth_enforced_pass_through_route(
1166-
route=route
1170+
route=route,
1171+
method=normalized_request_method,
11671172
)
11681173
):
11691174
# JWT team selection is team-scoped; key metadata is not
@@ -1619,6 +1624,7 @@ async def auth_builder(
16191624
parent_otel_span: Optional[Span],
16201625
proxy_logging_obj: ProxyLogging,
16211626
request_headers: Optional[dict] = None,
1627+
request_method: Optional[str] = None,
16221628
) -> JWTAuthBuilderResult:
16231629
"""Main authentication and authorization builder"""
16241630
# Check if OIDC UserInfo endpoint is enabled, but fall back to standard
@@ -1755,6 +1761,7 @@ async def auth_builder(
17551761
user_api_key_cache=user_api_key_cache,
17561762
parent_otel_span=parent_otel_span,
17571763
proxy_logging_obj=proxy_logging_obj,
1764+
request_method=request_method,
17581765
)
17591766
# Extract alias fields for resolution (if configured)
17601767
org_alias = jwt_handler.get_org_alias(token=jwt_valid_token, default_value=None)

litellm/proxy/auth/route_checks.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ def is_virtual_key_allowed_to_call_route(
103103
if len(valid_token.allowed_routes) == 0:
104104
return True
105105

106+
denied_auth_enforced_pass_through_route = False
107+
106108
# explicit check for allowed routes (exact match or prefix match)
107109
for allowed_route in valid_token.allowed_routes:
108110
if RouteChecks._route_matches_allowed_route(
@@ -129,6 +131,7 @@ def is_virtual_key_allowed_to_call_route(
129131
route=route, user_api_key_dict=valid_token
130132
):
131133
return True
134+
denied_auth_enforced_pass_through_route = True
132135
else:
133136
return True
134137

@@ -151,6 +154,7 @@ def is_virtual_key_allowed_to_call_route(
151154
route=route, user_api_key_dict=valid_token
152155
):
153156
return True
157+
denied_auth_enforced_pass_through_route = True
154158
else:
155159
return True
156160

@@ -176,6 +180,11 @@ def is_virtual_key_allowed_to_call_route(
176180
):
177181
return True
178182

183+
if denied_auth_enforced_pass_through_route:
184+
RouteChecks._require_auth_pass_through_access(
185+
route=route, valid_token=valid_token
186+
)
187+
179188
raise HTTPException(
180189
status_code=status.HTTP_403_FORBIDDEN,
181190
detail=f"Virtual key is not allowed to call this route. Only allowed to call routes: {valid_token.allowed_routes}. Tried to call route: {route}",

litellm/proxy/auth/user_api_key_auth.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,9 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
917917
proxy_logging_obj=proxy_logging_obj,
918918
parent_otel_span=parent_otel_span,
919919
request_headers=_safe_get_request_headers(request),
920+
request_method=RouteChecks._get_request_method(
921+
request=request
922+
),
920923
)
921924

922925
is_proxy_admin = result["is_proxy_admin"]

tests/test_litellm/proxy/auth/test_handle_jwt.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,82 @@ async def test_find_team_with_model_access_reports_passthrough_allowlist_denial(
188188
assert user_api_key_dict.team_metadata == {}
189189

190190

191+
@pytest.mark.asyncio
192+
async def test_find_team_with_model_access_uses_request_method_for_passthrough_auth():
193+
jwt_handler = JWTHandler()
194+
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
195+
team = LiteLLM_TeamTable(
196+
team_id="team-a",
197+
models=["gpt-4"],
198+
metadata={},
199+
)
200+
mock_registered_routes = {
201+
"test-uuid-1:exact:/custom:GET": {
202+
"endpoint_id": "test-uuid-1",
203+
"path": "/custom",
204+
"type": "exact",
205+
"methods": ["GET"],
206+
"passthrough_params": {"dependencies": None},
207+
},
208+
"test-uuid-2:exact:/custom:POST": {
209+
"endpoint_id": "test-uuid-2",
210+
"path": "/custom",
211+
"type": "exact",
212+
"methods": ["POST"],
213+
"passthrough_params": {"dependencies": [object()]},
214+
},
215+
}
216+
217+
with (
218+
patch(
219+
"litellm.proxy.auth.handle_jwt.get_team_object",
220+
new_callable=AsyncMock,
221+
return_value=team,
222+
),
223+
patch(
224+
"litellm.proxy.auth.handle_jwt.allowed_routes_check",
225+
return_value=True,
226+
),
227+
patch(
228+
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
229+
mock_registered_routes,
230+
),
231+
patch(
232+
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path",
233+
return_value="/",
234+
),
235+
):
236+
team_id, team_obj = await JWTAuthManager.find_team_with_model_access(
237+
team_ids={"team-a"},
238+
requested_model=None,
239+
route="/custom",
240+
jwt_handler=jwt_handler,
241+
prisma_client=None,
242+
user_api_key_cache=MagicMock(),
243+
parent_otel_span=None,
244+
proxy_logging_obj=MagicMock(),
245+
request_method="GET",
246+
)
247+
assert team_id == "team-a"
248+
assert team_obj == team
249+
250+
with pytest.raises(HTTPException) as exc_info:
251+
await JWTAuthManager.find_team_with_model_access(
252+
team_ids={"team-a"},
253+
requested_model=None,
254+
route="/custom",
255+
jwt_handler=jwt_handler,
256+
prisma_client=None,
257+
user_api_key_cache=MagicMock(),
258+
parent_otel_span=None,
259+
proxy_logging_obj=MagicMock(),
260+
request_method="POST",
261+
)
262+
263+
assert exc_info.value.status_code == 403
264+
assert "allowed_passthrough_routes" in exc_info.value.detail
265+
266+
191267
@pytest.mark.asyncio
192268
async def test_auth_builder_proxy_admin_user_role():
193269
"""Test that is_proxy_admin is True when user_object.user_role is PROXY_ADMIN"""

tests/test_litellm/proxy/auth/test_route_checks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,7 @@ def test_virtual_key_llm_api_routes_denies_auth_pass_through_without_allowlist()
783783
valid_token=valid_token,
784784
)
785785
assert exc_info.value.status_code == 403
786+
assert "allowed_passthrough_routes" in exc_info.value.detail
786787

787788

788789
def test_virtual_key_llm_api_routes_uses_method_specific_auth_setting():

tests/test_litellm/proxy/auth/test_user_api_key_auth.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,7 @@ async def test_both_enabled_jwt_token_skips_oauth2(self):
15131513

15141514
mock_request = MagicMock()
15151515
mock_request.url.path = "/v1/chat/completions"
1516+
mock_request.method = "POST"
15161517
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
15171518
mock_request.query_params = {}
15181519

@@ -1546,6 +1547,7 @@ async def test_both_enabled_jwt_token_skips_oauth2(self):
15461547
mock_oauth2.assert_not_called()
15471548
# JWT auth SHOULD be called
15481549
mock_jwt_auth.assert_called_once()
1550+
assert mock_jwt_auth.call_args.kwargs["request_method"] == "POST"
15491551
assert result.user_id == "jwt-human-user"
15501552

15511553
@pytest.mark.asyncio

0 commit comments

Comments
 (0)