From 76f78727ee26b4b366c3e75abb1f589b50930002 Mon Sep 17 00:00:00 2001 From: IdoPort Date: Mon, 14 Sep 2026 12:18:31 +0300 Subject: [PATCH] fix(security): only reorder pass-through routes that actually overlap a generic provider route Addresses the veria-ai automated security review on PR #38017 (https://github.com/BerriAI/litellm/pull/38017#discussion_r4003591579). _move_before_generic_provider_routes previously moved ANY newly-appended route ahead of the first route containing "{provider}" in its path, unconditionally. Since insertion is positional, this also promoted the new route ahead of every other route registered afterward -- including unrelated, authenticated built-in routes. A wildcard pass-through configured with an overlapping prefix (e.g. "/key/{subpath:path}") would shadow "/key/generate" and receive request bodies meant for the management API. Now the candidate generic route's template must actually match the new route's own path (via the same route.matches() check the test file's _resolve_route_name helper already uses) before any reordering happens. A route whose own path never structurally resembles a "/{provider}/..." template -- any wildcard/parameterized path -- is left exactly where it was appended, same as when no generic route exists at all. Adds test_wildcard_pass_through_does_not_shadow_unrelated_built_in_routes covering the "/key/{subpath:path}" vs "/key/generate" scenario from the review. --- .../pass_through_endpoints.py | 41 ++++++++++++++----- .../test_llm_pass_through_endpoints.py | 38 +++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 8e43b0f52d3..0b0733be4a7 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -26,6 +26,7 @@ from fastapi import ( ) from fastapi.responses import StreamingResponse from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.routing import Match from starlette.websockets import WebSocketState from websockets.asyncio.client import connect from websockets.exceptions import ( @@ -2772,24 +2773,44 @@ class SafeRouteAdder: https://github.com/BerriAI/litellm/issues/37925). Move the just-appended route (the last item in app.routes) to sit immediately - before the first such generic route, so it is matched first instead. If no - generic provider route is registered (e.g. a minimal deployment), leave the - route appended -- current behavior is preserved as a safe fallback. + before the first generic route whose template would actually capture this + route's own path, so it is matched first instead. A route is moved only when + that genuine overlap exists: reordering unconditionally on any "{provider}" + sighting would also promote unrelated wildcard pass-throughs (e.g. a + "/key/{subpath:path}" entry) ahead of every route registered afterward, + including unrelated, authenticated built-in routes like "/key/generate" -- + a real route.matches() check on this route's own path rules that out, since + a route path containing its own "{...}" placeholder never structurally + matches a "/{provider}/..." template. If no generic provider route captures + this path (including when none is registered at all, e.g. a minimal + deployment), leave the route appended -- current behavior is preserved as a + safe fallback. Builds the reordered list in one expression and reassigns app.router.routes wholesale, rather than mutating the existing list in place with pop()/insert(). """ routes: Final = app.routes new_route: Final = routes[-1] + scope: Final = { + "type": "http", + "method": "GET", + "path": new_route.path, + "headers": [], + "query_string": b"", + "root_path": "", + } for index, route in enumerate(routes[:-1]): route_path = getattr(route, "path", None) - if route_path and SafeRouteAdder._GENERIC_PROVIDER_PATH_MARKER in route_path: - app.router.routes = [ # mutable-ok: framework's list # rebind-ok: reordering is the fix - *routes[:index], - new_route, - *routes[index:-1], - ] - return + if not (route_path and SafeRouteAdder._GENERIC_PROVIDER_PATH_MARKER in route_path): + continue + if route.matches(scope)[0] == Match.NONE: + continue + app.router.routes = [ # mutable-ok: framework's list # rebind-ok: reordering is the fix + *routes[:index], + new_route, + *routes[index:-1], + ] + return @staticmethod def add_api_route_if_not_exists( diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index 4296314fdf9..c650a93d42a 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -3451,6 +3451,44 @@ def test_custom_pass_through_endpoint_prefix_wins_over_native_provider_routes(): ] +def test_wildcard_pass_through_does_not_shadow_unrelated_built_in_routes(): + """ + A pass_through_endpoints entry with a wildcard subpath (e.g. "/key/{subpath:path}") + must not be promoted ahead of unrelated, authenticated built-in routes like + "/key/generate" just because it lands after the first "/{provider}/..." route in + app.routes once appended. Its own path never structurally matches a + "/{provider}/..." template, so it must be left exactly where it was appended. + """ + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + InitPassThroughEndpointHelpers, + ) + from litellm.proxy.proxy_server import app + + key_generate_route_name_before = _resolve_route_name("POST", "/key/generate") + assert key_generate_route_name_before is not None + + added_paths = {"/key/{subpath:path}"} + try: + InitPassThroughEndpointHelpers.add_exact_path_route( + app=app, + path="/key/{subpath:path}", + target="https://example.com/", + custom_headers=None, + forward_headers=False, + merge_query_params=False, + dependencies=None, + cost_per_request=None, + endpoint_id="test-key-wildcard", + ) + + assert _resolve_route_name("POST", "/key/generate") == key_generate_route_name_before + finally: + app.router.routes = [ + route for route in app.router.routes + if getattr(route, "path", None) not in added_paths + ] + + def test_move_before_generic_provider_routes_is_a_no_op_without_a_generic_route(): """ If no generic "/{provider}/..." route is registered on the app (e.g. a minimal