diff --git a/litellm/passthrough/utils.py b/litellm/passthrough/utils.py index 8ae32d37a93..5dde13f0078 100644 --- a/litellm/passthrough/utils.py +++ b/litellm/passthrough/utils.py @@ -3,8 +3,26 @@ from urllib.parse import parse_qs import httpx +from litellm._logging import verbose_logger from litellm.constants import PASS_THROUGH_HEADER_PREFIX +# Headers that must not be overwritten via the x-pass- forwarding mechanism. +# Includes standard credential/auth headers and protocol-level headers that +# affect routing or message framing. +_PASS_THROUGH_PROTECTED_HEADERS: frozenset = frozenset( + { + "authorization", + "api-key", + "x-api-key", + "x-goog-api-key", + "host", + "content-length", + } +) + +# Header name prefix used to block AWS SigV4 signing headers from being overridden. +_PASS_THROUGH_PROTECTED_HEADER_PREFIXES: tuple = ("x-amz-",) + class BasePassthroughUtils: @staticmethod @@ -57,13 +75,19 @@ class BasePassthroughUtils: headers = {**request_headers, **headers} # Process x-pass- prefixed headers (strip prefix and forward) - # Certain protocol-level and credential headers are excluded from this mechanism. - _PROTECTED_HEADERS = {"authorization", "api-key", "host", "content-length"} + # 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 to get the actual header name - actual_header_name = header_name[len(PASS_THROUGH_HEADER_PREFIX) :] - if actual_header_name.lower() in _PROTECTED_HEADERS: + # 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 diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py index eb4749549c2..b6dd714a232 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py @@ -451,6 +451,60 @@ def test_forward_headers_from_request_x_pass_prefix(): assert "x-pass-custom-header" not in result +def test_forward_headers_from_request_protected_headers_not_overwritten(): + """ + Test that x-pass- headers whose stripped names resolve to credential or + protocol-level header names are silently dropped and do not overwrite + values already present in the outbound headers dict. + """ + from litellm.passthrough.utils import BasePassthroughUtils + + proxy_headers = { + "authorization": "Bearer proxy-upstream-key", + "api-key": "proxy-azure-key", + "x-api-key": "proxy-anthropic-key", + "x-goog-api-key": "proxy-google-key", + } + + request_headers = { + "x-pass-authorization": "Bearer attacker-key", + "x-pass-api-key": "attacker-azure-key", + "x-pass-x-api-key": "attacker-anthropic-key", + "x-pass-x-goog-api-key": "attacker-google-key", + "x-pass-host": "evil.example.com", + "x-pass-content-length": "0", + "x-pass-x-amz-security-token": "attacker-aws-token", + # Legitimate x-pass- header that should still be forwarded + "x-pass-anthropic-beta": "context-1m-2025-08-07", + "content-type": "application/json", + } + + result = BasePassthroughUtils.forward_headers_from_request( + request_headers=request_headers, + headers=proxy_headers.copy(), + forward_headers=False, + ) + + # Protected headers must retain the proxy-configured values + assert result["authorization"] == "Bearer proxy-upstream-key" + assert result["api-key"] == "proxy-azure-key" + assert result["x-api-key"] == "proxy-anthropic-key" + assert result["x-goog-api-key"] == "proxy-google-key" + + # Protocol headers must not be injected + assert "host" not in result + assert "content-length" not in result + + # AWS SigV4 headers must not be injected + assert "x-amz-security-token" not in result + + # Legitimate non-protected x-pass- header still forwarded + assert result["anthropic-beta"] == "context-1m-2025-08-07" + + # Header name must be normalized to lowercase in output + assert "Anthropic-Beta" not in result + + @pytest.mark.asyncio async def test_vertex_passthrough_custom_model_name_replaced_in_url(): """