From 368380e99357638f472896ce75a8130b034c0037 Mon Sep 17 00:00:00 2001 From: lzhan011 <35493221+lzhan011@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:04:53 -0500 Subject: [PATCH] fix(proxy): enforce auth defaults for raw pass-through config The registration side and the authorization side read the same `auth` key with different defaults, so a raw pass-through entry that simply omits it was registered as authenticated and then skipped authorization: _register_pass_through_endpoint: endpoint_data.get("auth") -> None _run_centralized_common_checks: endpoint.get("auth") is not True -> True, return `None is not True` is true, so the skip-list matched and `_run_centralized_common_checks` returned before `common_checks` ran. Both sides now default the key to True, which is what the typed `PassThroughGenericEndpoint` already documents. Rebuilt on `main`: this PR was opened against the since-deleted `litellm_internal_staging`, and both reads are unchanged on `main`. The tests moved upstream -- `tests/test_litellm/` is now `tests/unit/`, and the centralized-checks suite now lives in `tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py` rather than `test_user_api_key_auth.py` -- so the new cases are placed there. Two tests: a config entry with no `auth` key must still reach `common_checks` (asserted through the clientside-metadata rejection it raises), and registration must record `auth is True` for the same entry. Both fail against `main`'s reads (`2 failed | 5 passed`); 8 pass here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016SxQ2jq4B4KLGwPoLgfHye --- litellm/proxy/auth/user_api_key_auth.py | 6 ++- .../pass_through_endpoints.py | 2 +- .../test_user_api_key_auth_request_flow.py | 48 +++++++++++++++++++ .../test_passthrough_auth_default.py | 33 ++++++++++++- 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index cb39801a8e0..e655607ca01 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2857,7 +2857,11 @@ async def _run_centralized_common_checks( pass_through_endpoints: Final = general_settings.get("pass_through_endpoints", None) if not force_virtual_key_checks and pass_through_endpoints is not None: for endpoint in pass_through_endpoints: - if isinstance(endpoint, dict) and endpoint.get("path", "") == route and endpoint.get("auth") is not True: + if ( + isinstance(endpoint, dict) + and endpoint.get("path", "") == route + and endpoint.get("auth", True) is not True + ): return # No-auth dev mode: master_key unset AND no JWT/OAuth2 auth diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index f5ba7f9e877..b53ee9eac22 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -3340,7 +3340,7 @@ async def _register_pass_through_endpoint( forward_headers: Final = endpoint_data.get("forward_headers") merge_query_params: Final = endpoint_data.get("merge_query_params") default_query_params: Final = endpoint_data.get("default_query_params") - auth: Final[bool | str | None] = endpoint_data.get("auth") + auth: Final[bool | str | None] = endpoint_data.get("auth", True) dependencies = None auth_enforced: Final = auth is not None and str(auth).lower() == "true" diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 8b33202b483..e40f05f7459 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -4639,6 +4639,54 @@ async def test_centralized_common_checks_runs_for_standard_auth(): setattr(_proxy_server_mod, k, v) +@pytest.mark.asyncio +async def test_centralized_common_checks_runs_for_raw_passthrough_without_auth(): + """A raw pass-through entry that omits `auth` must still be authorized. + + The skip-list read was `endpoint.get("auth") is not True`, so an entry with + no `auth` key at all read as "auth disabled" and returned before + common_checks ran. The registration side defaults the same key to True, so + the two disagreed about the same config. + """ + import litellm.proxy.proxy_server as _proxy_server_mod + from fastapi import Request + from starlette.datastructures import URL + + route = "/chat/completions" + token = UserAPIKeyAuth(api_key="sk-test") + request = Request(scope={"type": "http", "headers": []}) + request._url = URL(url=route) + attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) + attrs["general_settings"] = { + "reject_clientside_metadata_tags": True, + "pass_through_endpoints": [ + { + "path": route, + "target": "https://example.com", + } + ], + } + originals = {attribute: getattr(_proxy_server_mod, attribute, None) for attribute in attrs} + try: + for attribute, value in attrs.items(): + setattr(_proxy_server_mod, attribute, value) + with pytest.raises(ProxyException) as exc_info: + await _run_centralized_common_checks( + user_api_key_auth_obj=token, + request=request, + request_data={ + "model": "gpt-4o", + "metadata": {"tags": ["client-supplied"]}, + }, + route=route, + ) + finally: + for attribute, value in originals.items(): + setattr(_proxy_server_mod, attribute, value) + + assert exc_info.value.type == ProxyErrorTypes.bad_request_error + + @pytest.mark.asyncio @pytest.mark.parametrize( "route", diff --git a/tests/unit/proxy/pass_through_endpoints/test_passthrough_auth_default.py b/tests/unit/proxy/pass_through_endpoints/test_passthrough_auth_default.py index 5da08e6af75..31cadbc5107 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_passthrough_auth_default.py +++ b/tests/unit/proxy/pass_through_endpoints/test_passthrough_auth_default.py @@ -28,11 +28,16 @@ from fastapi.routing import APIRoute from fastapi.testclient import TestClient -from litellm.proxy._types import PassThroughGenericEndpoint, ProxyException +from litellm.proxy._types import ( + LiteLLMRoutes, + PassThroughGenericEndpoint, + ProxyException, +) from litellm.proxy.auth.user_api_key_auth import ( check_api_key_for_custom_headers_or_pass_through_endpoints, ) from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _registered_pass_through_routes, _register_pass_through_endpoint, ) from litellm.proxy.proxy_server import openai_exception_handler @@ -85,6 +90,32 @@ async def test_register_passthrough_with_auth_true_works_for_oss(monkeypatch: py assert response.json()["error"]["type"] == "auth_error" +@pytest.mark.asyncio +async def test_register_raw_passthrough_defaults_to_authentication(): + path = "/raw-auth-default" + route_key = f"raw-auth-default:exact:{path}:GET" + endpoint: dict[str, object] = { + "id": "raw-auth-default", + "path": path, + "target": "https://example.com", + "methods": ["GET"], + } + + try: + await _register_pass_through_endpoint( + endpoint=endpoint, + app=FastAPI(), + premium_user=False, + visited_endpoints=set(), + ) + assert _registered_pass_through_routes[route_key]["auth"] is True + assert path in LiteLLMRoutes.openai_routes.value + finally: + _registered_pass_through_routes.pop(route_key, None) + if path in LiteLLMRoutes.openai_routes.value: + LiteLLMRoutes.openai_routes.value.remove(path) + + @pytest.mark.asyncio async def test_runtime_check_treats_missing_auth_key_as_authenticated(): # The runtime dispatch in user_api_key_auth pulls