From 961672efa2efc074f578e457fe9699b1ee7d6f0e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Apr 2026 18:09:49 -0700 Subject: [PATCH] fix: harden pass-through target URL construction Use posixpath-based joining when combining the operator-configured base target with request subpaths and provider endpoint paths so any path prefix on the base URL is preserved and '..' segments cannot climb out of it. Adds a shared HttpPassThroughEndpointHelpers.join_base_and_endpoint_path helper and routes the generic and provider pass-through handlers (Anthropic, Gemini, Mistral, Cohere, Cursor, Milvus, OpenAI, etc.) through it. --- .../llm_passthrough_endpoints.py | 106 ++++++++++++------ .../pass_through_endpoints.py | 31 ++++- 2 files changed, 100 insertions(+), 37 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 418715cb9cd..6521abffb85 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -127,18 +127,14 @@ async def llm_passthrough_factory_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - # Join paths correctly by removing trailing/leading slashes as needed - if not base_url.path or base_url.path == "/": - # If base URL has no path, just use the new path - updated_url = base_url.copy_with(path=encoded_endpoint) - else: - # Otherwise, combine the paths - base_path = base_url.path.rstrip("/") - clean_path = encoded_endpoint.lstrip("/") - full_path = f"{base_path}/{clean_path}" - updated_url = base_url.copy_with(path=full_path) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters provider_api_key = passthrough_endpoint_router.get_credentials( @@ -215,9 +211,14 @@ async def gemini_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters gemini_api_key: Optional[str] = passthrough_endpoint_router.get_credentials( @@ -275,9 +276,14 @@ async def cohere_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters cohere_api_key = passthrough_endpoint_router.get_credentials( @@ -401,9 +407,14 @@ async def mistral_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters mistral_api_key = passthrough_endpoint_router.get_credentials( @@ -546,9 +557,14 @@ async def milvus_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) ## CREATE PASS-THROUGH endpoint_func = create_pass_through_route( endpoint=endpoint, @@ -601,9 +617,14 @@ async def anthropic_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters anthropic_api_key = passthrough_endpoint_router.get_credentials( @@ -1055,9 +1076,14 @@ async def bedrock_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters from litellm.llms.bedrock.chat import BedrockConverseLLM @@ -1231,9 +1257,14 @@ async def assemblyai_proxy_route( if not encoded_endpoint.startswith("/"): encoded_endpoint = "/" + encoded_endpoint - # Construct the full target URL using httpx + # Construct the full target URL using httpx, preserving any base path + # prefix that the operator configured on base_target_url. base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) # Add or update query parameters assemblyai_api_key = passthrough_endpoint_router.get_credentials( @@ -2086,16 +2117,15 @@ class BaseOpenAIPassThroughHandler: """ Properly joins a base URL with a path, preserving any existing path in the base URL. """ - # Join paths correctly by removing trailing/leading slashes as needed - if not base_url.path or base_url.path == "/": - # If base URL has no path, just use the new path - joined_path_str = str(base_url.copy_with(path=path)) - else: - # Otherwise, combine the paths - base_path = base_url.path.rstrip("/") - clean_path = path.lstrip("/") - full_path = f"{base_path}/{clean_path}" - joined_path_str = str(base_url.copy_with(path=full_path)) + # Combine paths via the shared helper so any '..' in the path cannot + # climb above the configured base path. + joined_path_str = str( + base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, path + ) + ) + ) # Apply OpenAI-specific path handling for both branches if ( @@ -2176,7 +2206,11 @@ async def cursor_proxy_route( encoded_endpoint = "/" + encoded_endpoint base_url = httpx.URL(base_target_url) - updated_url = base_url.copy_with(path=encoded_endpoint) + updated_url = base_url.copy_with( + path=HttpPassThroughEndpointHelpers.join_base_and_endpoint_path( + base_url, encoded_endpoint + ) + ) auth_value = base64.b64encode(f"{cursor_api_key}:".encode("utf-8")).decode("ascii") diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 014ec83a4ba..70fdd3ceb88 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2,6 +2,7 @@ import ast import asyncio import copy import json +import posixpath import traceback from base64 import b64encode from datetime import datetime @@ -599,7 +600,35 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): if subpath.startswith("/"): subpath = subpath[1:] - return base_target + subpath + # Resolve any '..' segments in the subpath so it cannot climb above + # the base_target prefix that the operator configured. + safe_subpath = posixpath.normpath("/" + subpath).lstrip("/") + if safe_subpath == ".": + safe_subpath = "" + + return base_target + safe_subpath + + @staticmethod + def join_base_and_endpoint_path(base_url: httpx.URL, endpoint_path: str) -> str: + """ + Combine the path component of ``base_url`` with ``endpoint_path``. + + Preserves any path prefix configured on the base URL and resolves + ``..`` segments in the endpoint so the result stays within the base + path. + """ + base_path = base_url.path or "" + if not base_path or base_path == "/": + normalized_endpoint = posixpath.normpath("/" + endpoint_path.lstrip("/")) + return normalized_endpoint + + base_path = base_path.rstrip("/") + clean_endpoint = endpoint_path.lstrip("/") + combined = posixpath.normpath(base_path + "/" + clean_endpoint) + # If normalization climbs out of the base path, fall back to base. + if combined != base_path and not combined.startswith(base_path + "/"): + return base_path + "/" + return combined @staticmethod def _update_stream_param_based_on_request_body(