diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index a52ad14e293..0ff1bd9b9d6 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1581,15 +1581,9 @@ async def check_api_key_for_custom_headers_or_pass_through_endpoints( api_key = request.headers.get("litellm_user_api_key") or "" if pass_through_endpoints is not None: for endpoint in pass_through_endpoints: + if _request_matches_unauthenticated_pass_through(endpoint, route): + return UserAPIKeyAuth() if isinstance(endpoint, dict) and endpoint.get("path", "") == route: - ## IF AUTH DISABLED - # Default to True: a config dict with no ``auth`` key - # otherwise produced an unauthenticated forwarder. The - # Pydantic ``PassThroughGenericEndpoint.auth`` default - # is also True, but raw config dicts skip that path — - # so this runtime check has to default to True too. - if endpoint.get("auth", True) is not True: - return UserAPIKeyAuth() ## IF AUTH ENABLED ### IF CUSTOM PARSER REQUIRED if endpoint.get("custom_auth_parser") is not None and endpoint.get("custom_auth_parser") == "langfuse": @@ -3610,6 +3604,18 @@ async def _load_team_member_default_model_budget( ) +def _request_matches_unauthenticated_pass_through(endpoint: object, route: str) -> bool: + if not isinstance(endpoint, dict): + return False + endpoint_data: Final = cast("dict[str, object]", endpoint) + if str(endpoint_data.get("auth", True)).lower() == "true": + return False + path: Final = endpoint_data.get("path") + if not isinstance(path, str) or not path: + return False + return route == path or (endpoint_data.get("include_subpath") is True and route.startswith(path.rstrip("/") + "/")) + + @tracer.wrap() async def run_centralized_common_checks( user_api_key_auth_obj: UserAPIKeyAuth, @@ -3672,7 +3678,7 @@ 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 _request_matches_unauthenticated_pass_through(endpoint, route): return # No-auth dev mode: master_key unset AND no JWT/OAuth2 auth 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 8440d3d2ab2..28d04c2cc9b 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 @@ -26,7 +26,7 @@ from litellm.redis.stores.connection import connect from litellm.redis.stores.spend_counters import SpendCounterStore from tests.unit.redis.stores.spend_counters_double import FakeSpendCounters from fastapi import HTTPException, Request, status -from starlette.datastructures import UploadFile +from starlette.datastructures import URL, UploadFile import litellm import litellm.proxy.proxy_server @@ -4846,6 +4846,40 @@ def _proxy_attrs_for_centralized_checks(user_custom_auth=None, flag=False, maste } +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoint, route, expect_common_checks", + [ + ({"path": "/orx", "include_subpath": True, "auth": False}, "/orx/rerank", False), + ({"path": "/orx", "include_subpath": True, "auth": False}, "/orx", False), + ({"path": "/orx", "auth": False}, "/orx/rerank", True), + ({"path": "/orx", "include_subpath": True, "auth": False}, "/orxfoo", True), + ({"path": "/orx", "include_subpath": True}, "/orx/rerank", True), + ({"path": "/orx", "include_subpath": True, "auth": True}, "/orx/rerank", True), + ({"path": "/orx", "include_subpath": True, "auth": "true"}, "/orx/rerank", True), + ], +) +async def test_centralized_common_checks_include_subpath_pass_through_authz( + endpoint: dict[str, object], route: str, expect_common_checks: bool +) -> None: + token = UserAPIKeyAuth(api_key="sk-test", user_id="u1") + request = Request(scope={"type": "http", "method": "POST", "headers": [], "query_string": b""}) + request._url = URL(url=route) + attrs = _proxy_attrs_for_centralized_checks() + attrs["general_settings"] = {"pass_through_endpoints": [endpoint]} + with ( + patch.multiple(litellm.proxy.proxy_server, **attrs), + patch("litellm.proxy.auth.user_api_key_auth.common_checks", new_callable=AsyncMock) as mock_checks, + ): + await run_centralized_common_checks( + user_api_key_auth_obj=token, + request=request, + request_data={}, + route=route, + ) + assert bool(mock_checks.await_count) is expect_common_checks + + @pytest.mark.asyncio async def test_centralized_common_checks_runs_for_standard_auth(): """Regardless of which _user_api_key_auth_builder path returned, the 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 7771ff0274e..889cfbdcbac 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 @@ -29,7 +29,7 @@ from fastapi.testclient import TestClient from starlette.requests import Request -from litellm.proxy._types import PassThroughGenericEndpoint, ProxyException +from litellm.proxy._types import PassThroughGenericEndpoint, ProxyException, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import ( check_api_key_for_custom_headers_or_pass_through_endpoints, ) @@ -116,12 +116,48 @@ async def test_runtime_check_treats_missing_auth_key_as_authenticated(): assert result == "sk-9876" +@pytest.mark.asyncio +async def test_runtime_check_unauthenticated_subpath_skips_validation() -> None: + request: Final = MagicMock() + request.headers = {} + endpoint: Final = { + "path": "/public-webhook", + "target": "https://example.com", + "include_subpath": True, + "auth": False, + } + + result: Final = await check_api_key_for_custom_headers_or_pass_through_endpoints( + request=request, + route="/public-webhook/event", + pass_through_endpoints=[endpoint], + api_key="", + ) + + assert isinstance(result, UserAPIKeyAuth) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth", ["true", "TRUE"]) +async def test_runtime_check_string_auth_true_requires_validation(auth: str) -> None: + request: Final = MagicMock() + request.headers = {} + endpoint: Final = {"path": "/forwarder", "target": "https://example.com", "auth": auth} + + result: Final = await check_api_key_for_custom_headers_or_pass_through_endpoints( + request=request, + route="/forwarder", + pass_through_endpoints=[endpoint], + api_key="sk-9876", + ) + + assert result == "sk-9876" + + @pytest.mark.asyncio async def test_runtime_check_explicit_auth_false_still_skips_validation(): # Operators who explicitly set ``auth: False`` get the legacy # behaviour — an empty UserAPIKeyAuth, no key required. - from litellm.proxy._types import UserAPIKeyAuth - request = MagicMock() request.headers = {} raw_endpoint_auth_false = {