fix(fallbacks): gate x-litellm-* passthrough to trusted callers only

The previous patch unconditionally let `x-litellm-*` keys bypass the
`llm_provider-` prefix in `process_response_headers`. That function is
also called on raw upstream-provider response headers (e.g. from
`llm_http_handler.py`), so a malicious provider could return
`x-litellm-attempted-fallbacks` and spoof a LiteLLM-internal marker,
bypassing the proxy model-override guard.

Add a `preserve_litellm_internal_headers` flag (default False). Only
`response_metadata.py`, which re-processes the already-built
`_hidden_params["additional_headers"]` dict (LiteLLM-owned), passes
True. Raw provider header callsites keep the default False, so upstream
`x-litellm-*` still gets the `llm_provider-` prefix.

Adds a regression test for the spoofing case and renames the existing
preserve test to make the trusted-path semantics explicit.
This commit is contained in:
Varshith 2026-05-19 13:06:55 -05:00
parent 366d8d1db9
commit 068e4ade1b
3 changed files with 41 additions and 8 deletions

View file

@ -242,7 +242,18 @@ def _get_parent_otel_span_from_kwargs(
return None
def process_response_headers(response_headers: Union[httpx.Headers, dict]) -> dict:
def process_response_headers(
response_headers: Union[httpx.Headers, dict],
preserve_litellm_internal_headers: bool = False,
) -> dict:
"""
`preserve_litellm_internal_headers` must only be True when the input dict is
a LiteLLM-owned structure (e.g. `_hidden_params["additional_headers"]` that
has already been through one round of processing). For raw upstream
provider headers it must remain False, otherwise a malicious provider
returning `x-litellm-*` could spoof LiteLLM-internal markers
(e.g. `x-litellm-attempted-fallbacks`).
"""
from litellm.types.utils import OPENAI_RESPONSE_HEADERS
openai_headers = {}
@ -256,7 +267,7 @@ def process_response_headers(response_headers: Union[httpx.Headers, dict]) -> di
"llm_provider-"
): # return raw provider headers (incl. openai-compatible ones)
processed_headers[k] = v
elif k.startswith("x-litellm-"):
elif preserve_litellm_internal_headers and k.startswith("x-litellm-"):
# LiteLLM's own internal headers (e.g. x-litellm-attempted-fallbacks,
# x-litellm-model-group) are not LLM provider headers and must not be
# prefixed. Downstream consumers (proxy override, callers checking

View file

@ -49,7 +49,8 @@ class ResponseMetadata:
result=self.result, litellm_model_name=model, router_model_id=model_id
),
"additional_headers": process_response_headers(
self._get_value_from_hidden_params("additional_headers") or {}
self._get_value_from_hidden_params("additional_headers") or {},
preserve_litellm_internal_headers=True,
),
"litellm_model_name": model,
}

View file

@ -109,20 +109,41 @@ async def test_async_completion_with_fallbacks_header_is_zero_when_primary_succe
assert response.choices[0].message.content == "primary-resp"
def test_process_response_headers_preserves_x_litellm_headers():
def test_process_response_headers_preserves_x_litellm_headers_when_internal():
"""
`process_response_headers` must not add the `llm_provider-` prefix to
LiteLLM's own internal headers (anything starting with `x-litellm-`).
These are markers set by LiteLLM (e.g. fallback / retry headers); the
proxy and other callers look up the bare key.
LiteLLM's own internal headers (anything starting with `x-litellm-`) when
the caller has marked the input as LiteLLM-owned. These are markers set by
LiteLLM (e.g. fallback / retry headers); the proxy and other callers look
up the bare key.
"""
result = process_response_headers(
{
"x-litellm-attempted-fallbacks": 1,
"x-litellm-model-group": "gpt-4",
"x-stainless-arch": "arm64",
}
},
preserve_litellm_internal_headers=True,
)
assert result["x-litellm-attempted-fallbacks"] == 1
assert result["x-litellm-model-group"] == "gpt-4"
assert result["llm_provider-x-stainless-arch"] == "arm64"
def test_process_response_headers_prefixes_x_litellm_from_raw_provider():
"""
On raw upstream-provider headers (default `preserve_litellm_internal_headers=False`),
a header whose name starts with `x-litellm-` MUST still get the
`llm_provider-` prefix. Otherwise a malicious provider could return
`x-litellm-attempted-fallbacks` and spoof a LiteLLM-internal marker,
bypassing the proxy model-override guard.
"""
result = process_response_headers(
{
"x-litellm-attempted-fallbacks": 99,
"x-stainless-arch": "arm64",
}
)
assert "x-litellm-attempted-fallbacks" not in result
assert result["llm_provider-x-litellm-attempted-fallbacks"] == 99
assert result["llm_provider-x-stainless-arch"] == "arm64"