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.
This commit is contained in:
Yuneng Jiang 2026-04-24 18:09:49 -07:00
parent 9521b74e9a
commit 961672efa2
No known key found for this signature in database
2 changed files with 100 additions and 37 deletions

View file

@ -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")

View file

@ -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(