mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(security): strip proxy auth headers from pass_through_endpoints forwarding
When forward_headers=True on a generic pass_through_endpoint, the proxy's own Authorization header (carrying the LiteLLM master/virtual key) was forwarded verbatim to the upstream provider, and x-pass- prefixed headers leaked through with the prefix intact alongside the stripped version. Root cause: forward_headers_from_request only stripped content-length and host before merging request headers into the outbound set. Auth headers (authorization, api-key, x-api-key, x-goog-api-key) and x-pass- prefixed headers were included raw. Fix: strip all _PASS_THROUGH_PROTECTED_HEADERS and x-pass- prefixed headers from request_headers before the merge. x-pass- entries are snapshotted first so the prefix-stripping loop still processes them. Also removes authorization and x-api-key from the WebSocket passthrough forwarding allowlist (same leak vector). Fixes #32202 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
db60ce9574
commit
5ef39bea2e
3 changed files with 96 additions and 49 deletions
|
|
@ -56,7 +56,7 @@ class BasePassthroughUtils:
|
|||
request_headers: dict,
|
||||
headers: dict,
|
||||
forward_headers: Optional[bool] = False,
|
||||
):
|
||||
) -> dict:
|
||||
"""
|
||||
Helper to forward headers from original request.
|
||||
|
||||
|
|
@ -64,34 +64,36 @@ class BasePassthroughUtils:
|
|||
with the prefix stripped, regardless of forward_headers setting.
|
||||
e.g., 'x-pass-anthropic-beta: value' becomes 'anthropic-beta: value'
|
||||
"""
|
||||
x_pass_entries = tuple(
|
||||
(name, value)
|
||||
for name, value in request_headers.items()
|
||||
if name.lower().startswith(PASS_THROUGH_HEADER_PREFIX)
|
||||
)
|
||||
|
||||
if forward_headers is True:
|
||||
# Header We Should NOT forward
|
||||
request_headers.pop("content-length", None)
|
||||
request_headers.pop("host", None)
|
||||
for header_name in list(request_headers.keys()):
|
||||
lower = header_name.lower()
|
||||
if lower in _PASS_THROUGH_PROTECTED_HEADERS or lower.startswith(PASS_THROUGH_HEADER_PREFIX):
|
||||
request_headers.pop(header_name, None)
|
||||
|
||||
custom_header_names = {header_name.lower() for header_name in headers}
|
||||
for header_name in list(request_headers.keys()):
|
||||
if header_name.lower() in custom_header_names:
|
||||
request_headers.pop(header_name, None)
|
||||
|
||||
# Combine request headers with custom headers
|
||||
headers = {**request_headers, **headers}
|
||||
|
||||
# Process x-pass- prefixed headers (strip prefix and forward)
|
||||
# Credential and protocol-level headers are excluded from this mechanism.
|
||||
for header_name, header_value in request_headers.items():
|
||||
if header_name.lower().startswith(PASS_THROUGH_HEADER_PREFIX):
|
||||
# Strip the 'x-pass-' prefix and normalize to lowercase
|
||||
actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower()
|
||||
if actual_header_name in _PASS_THROUGH_PROTECTED_HEADERS or any(
|
||||
actual_header_name.startswith(p) for p in _PASS_THROUGH_PROTECTED_HEADER_PREFIXES
|
||||
):
|
||||
verbose_logger.debug(
|
||||
"x-pass- header %s maps to a protected header name; skipping",
|
||||
header_name,
|
||||
)
|
||||
continue
|
||||
headers[actual_header_name] = header_value
|
||||
for header_name, header_value in x_pass_entries:
|
||||
actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :].lower()
|
||||
if actual_header_name in _PASS_THROUGH_PROTECTED_HEADERS or any(
|
||||
actual_header_name.startswith(p) for p in _PASS_THROUGH_PROTECTED_HEADER_PREFIXES
|
||||
):
|
||||
verbose_logger.debug(
|
||||
"x-pass- header %s maps to a protected header name; skipping",
|
||||
header_name,
|
||||
)
|
||||
continue
|
||||
headers[actual_header_name] = header_value
|
||||
|
||||
return headers
|
||||
|
||||
|
|
|
|||
|
|
@ -1833,13 +1833,9 @@ async def websocket_passthrough_request(
|
|||
upstream_headers = custom_headers.copy()
|
||||
|
||||
if forward_headers:
|
||||
# Forward relevant headers from the incoming request
|
||||
incoming_headers = dict(websocket.headers)
|
||||
for header_name, header_value in incoming_headers.items():
|
||||
# Only forward certain headers to avoid conflicts
|
||||
if header_name.lower() in [
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-goog-user-project",
|
||||
]:
|
||||
upstream_headers[header_name] = header_value
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ async def test_vertex_passthrough_load_balancing():
|
|||
new_callable=AsyncMock,
|
||||
) as mock_auth,
|
||||
):
|
||||
|
||||
# Setup additional mocks to avoid side effects
|
||||
mock_pt_router.get_vertex_credentials.return_value = MagicMock()
|
||||
mock_prep_headers.return_value = (
|
||||
|
|
@ -86,9 +85,7 @@ async def test_vertex_passthrough_load_balancing():
|
|||
|
||||
# Verify
|
||||
# 1. Check that get_available_deployment_for_pass_through was called with the correct model ID
|
||||
mock_router.get_available_deployment_for_pass_through.assert_called_once_with(
|
||||
model="gemini-pro"
|
||||
)
|
||||
mock_router.get_available_deployment_for_pass_through.assert_called_once_with(model="gemini-pro")
|
||||
|
||||
# 2. Check that get_model_list was NOT called (this ensures we aren't doing the old logic)
|
||||
mock_router.get_model_list.assert_not_called()
|
||||
|
|
@ -210,9 +207,7 @@ def test_get_available_deployment_for_pass_through_load_balancing():
|
|||
# Call multiple times and track selected deployments
|
||||
selections = {"project-1": 0, "project-2": 0}
|
||||
for _ in range(100):
|
||||
deployment = router.get_available_deployment_for_pass_through(
|
||||
model="gemini-pro"
|
||||
)
|
||||
deployment = router.get_available_deployment_for_pass_through(model="gemini-pro")
|
||||
project = deployment["litellm_params"]["vertex_project"]
|
||||
selections[project] += 1
|
||||
|
||||
|
|
@ -241,9 +236,7 @@ async def test_async_get_available_deployment_for_pass_through():
|
|||
|
||||
router = Router(model_list=model_list, routing_strategy="simple-shuffle")
|
||||
|
||||
deployment = await router.async_get_available_deployment_for_pass_through(
|
||||
model="gemini-pro", request_kwargs={}
|
||||
)
|
||||
deployment = await router.async_get_available_deployment_for_pass_through(model="gemini-pro", request_kwargs={})
|
||||
|
||||
assert deployment is not None
|
||||
assert deployment["litellm_params"]["use_in_pass_through"] is True
|
||||
|
|
@ -307,7 +300,6 @@ async def test_vertex_passthrough_forwards_anthropic_beta_header():
|
|||
return_value=("new-access-token", None),
|
||||
) as mock_get_token,
|
||||
):
|
||||
|
||||
# Call the function
|
||||
(
|
||||
headers,
|
||||
|
|
@ -402,7 +394,6 @@ async def test_vertex_passthrough_does_not_forward_litellm_auth_token():
|
|||
return_value=("vertex-access-token", None),
|
||||
),
|
||||
):
|
||||
|
||||
(
|
||||
headers,
|
||||
_base_target_url,
|
||||
|
|
@ -621,7 +612,6 @@ async def test_vertex_passthrough_custom_model_name_replaced_in_url():
|
|||
new_callable=AsyncMock,
|
||||
) as mock_auth,
|
||||
):
|
||||
|
||||
mock_pt_router.get_vertex_credentials.return_value = MagicMock()
|
||||
mock_prep_headers.return_value = (
|
||||
{},
|
||||
|
|
@ -634,9 +624,7 @@ async def test_vertex_passthrough_custom_model_name_replaced_in_url():
|
|||
mock_create_route.return_value = mock_endpoint_func
|
||||
mock_auth.return_value = {}
|
||||
|
||||
mock_handler.get_default_base_target_url.return_value = (
|
||||
"https://global-aiplatform.googleapis.com"
|
||||
)
|
||||
mock_handler.get_default_base_target_url.return_value = "https://global-aiplatform.googleapis.com"
|
||||
|
||||
await _base_vertex_proxy_route(
|
||||
endpoint=test_endpoint,
|
||||
|
|
@ -646,17 +634,78 @@ async def test_vertex_passthrough_custom_model_name_replaced_in_url():
|
|||
)
|
||||
|
||||
# Verify the router was called with the custom model name (extracted from URL)
|
||||
mock_router.get_available_deployment_for_pass_through.assert_called_once_with(
|
||||
model="gcp/google/gemini-3-pro"
|
||||
)
|
||||
mock_router.get_available_deployment_for_pass_through.assert_called_once_with(model="gcp/google/gemini-3-pro")
|
||||
|
||||
# Verify the target URL passed to create_pass_through_route contains
|
||||
# the REAL Vertex AI model name, not the custom one
|
||||
create_route_call = mock_create_route.call_args
|
||||
target_url = create_route_call.kwargs.get("target", "")
|
||||
assert (
|
||||
"gcp/google/gemini-3-pro" not in target_url
|
||||
), f"Custom model name should have been replaced in target URL. Got: {target_url}"
|
||||
assert (
|
||||
"gemini-3-pro" in target_url
|
||||
), f"Actual Vertex AI model name should be in target URL. Got: {target_url}"
|
||||
assert "gcp/google/gemini-3-pro" not in target_url, (
|
||||
f"Custom model name should have been replaced in target URL. Got: {target_url}"
|
||||
)
|
||||
assert "gemini-3-pro" in target_url, f"Actual Vertex AI model name should be in target URL. Got: {target_url}"
|
||||
|
||||
|
||||
def test_forward_headers_strips_proxy_auth_when_forwarding():
|
||||
"""
|
||||
Regression test for #32202: when forward_headers=True and custom headers
|
||||
do NOT include an Authorization key, the proxy's own auth header must
|
||||
still be stripped so it is never forwarded to the upstream provider.
|
||||
"""
|
||||
from litellm.passthrough.utils import BasePassthroughUtils
|
||||
|
||||
request_headers = {
|
||||
"authorization": "Bearer sk-litellm-master",
|
||||
"api-key": "sk-litellm-azure",
|
||||
"x-api-key": "sk-litellm-anthropic",
|
||||
"x-goog-api-key": "sk-litellm-google",
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-456",
|
||||
}
|
||||
custom_headers = {
|
||||
"x-custom": "custom-value",
|
||||
}
|
||||
|
||||
result = BasePassthroughUtils.forward_headers_from_request(
|
||||
request_headers=request_headers.copy(),
|
||||
headers=custom_headers.copy(),
|
||||
forward_headers=True,
|
||||
)
|
||||
|
||||
assert "authorization" not in result
|
||||
assert "api-key" not in result
|
||||
assert "x-api-key" not in result
|
||||
assert "x-goog-api-key" not in result
|
||||
assert result["x-custom"] == "custom-value"
|
||||
assert result["content-type"] == "application/json"
|
||||
assert result["x-request-id"] == "req-456"
|
||||
|
||||
|
||||
def test_forward_headers_no_raw_x_pass_prefix_in_output():
|
||||
"""
|
||||
Regression test for #32202: when forward_headers=True, x-pass- prefixed
|
||||
headers must not appear verbatim in the forwarded set. Only the
|
||||
prefix-stripped version should be present.
|
||||
"""
|
||||
from litellm.passthrough.utils import BasePassthroughUtils
|
||||
|
||||
request_headers = {
|
||||
"authorization": "Bearer sk-litellm-key",
|
||||
"x-pass-anthropic-beta": "prompt-caching-2025-04",
|
||||
"x-pass-custom-header": "custom-value",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
custom_headers = {"x-api-key": "sk-real-provider-key"}
|
||||
|
||||
result = BasePassthroughUtils.forward_headers_from_request(
|
||||
request_headers=request_headers.copy(),
|
||||
headers=custom_headers.copy(),
|
||||
forward_headers=True,
|
||||
)
|
||||
|
||||
assert "x-pass-anthropic-beta" not in result
|
||||
assert "x-pass-custom-header" not in result
|
||||
assert result["anthropic-beta"] == "prompt-caching-2025-04"
|
||||
assert result["custom-header"] == "custom-value"
|
||||
assert "authorization" not in result
|
||||
assert result["x-api-key"] == "sk-real-provider-key"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue