fix(a2a): let an agent refuse caller credentials sent through convention headers

Any caller allowed to invoke an agent could set x-a2a-{agent}-authorization,
-cookie, -proxy-authorization or -x-api-key and have the proxy send that value
to the backend agent. Agents whose backend trusts those headers can now set
litellm_params.block_convention_credential_headers to true, which drops those
four names from convention forwarding while static headers, extra_headers and
other convention headers keep working. Agents that leave it unset behave as
before, matching the documented convention

Fixes #49427
This commit is contained in:
Rameshwaran Mohan 2026-11-01 13:32:47 +05:30
parent 60fc121ce8
commit 66d7fb6570
2 changed files with 104 additions and 8 deletions

View file

@ -11,7 +11,8 @@ The A2A SDK can point to LiteLLM's URL and invoke agents registered with LiteLLM
"""
import json
from collections.abc import AsyncGenerator, Callable, Coroutine, Mapping
from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Iterator, Mapping
from itertools import chain
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
from urllib.parse import urlparse
@ -272,6 +273,32 @@ async def _log_agent_rejection(
verbose_proxy_logger.exception("Failed to log rejected A2A request")
_CONVENTION_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "proxy-authorization", "cookie", "x-api-key"})
def _convention_headers(
headers: Mapping[str, str],
aliases: Iterable[str],
*,
block_credentials: bool,
) -> Mapping[str, str]:
"""Collect x-a2a-{agent_id_or_name}-{header_name} headers, case-insensitive.
Agents that set block_convention_credential_headers never receive caller
credentials this way, only their own static headers.
"""
stripped: Final = chain.from_iterable(_strip_prefix(headers, f"x-a2a-{alias.lower()}-") for alias in aliases)
return {
name: val
for name, val in stripped
if name and not (block_credentials and name in _CONVENTION_CREDENTIAL_HEADERS)
}
def _strip_prefix(headers: Mapping[str, str], prefix: str) -> Iterator[tuple[str, str]]:
return ((key.lower()[len(prefix) :], val) for key, val in headers.items() if key.lower().startswith(prefix))
def _jsonrpc_error(
request_id: object,
code: int,
@ -977,13 +1004,13 @@ async def invoke_agent_a2a(
# 2. Convention-based forwarding: x-a2a-{agent_id_or_name}-{header_name}
# Matches both agent_id (UUID) and agent_name (alias), case-insensitive.
for alias in (agent.agent_id.lower(), agent.agent_name.lower()):
prefix = f"x-a2a-{alias}-"
for key, val in normalized.items():
if key.startswith(prefix):
header_name = key[len(prefix) :]
if header_name:
dynamic_headers[header_name] = val
dynamic_headers.update(
_convention_headers(
normalized,
(agent.agent_id, agent.agent_name),
block_credentials=litellm_params.get("block_convention_credential_headers") is True,
)
)
merged_agent_headers: Final = merge_agent_headers(
dynamic_headers=dynamic_headers or None,

View file

@ -313,6 +313,75 @@ async def test_convention_unrelated_prefix_not_forwarded():
assert headers == {"X-LiteLLM-User-Id": "u1"}
@pytest.mark.parametrize(
"block_credentials, expected",
[
(
False,
{
"authorization": "Bearer caller",
"cookie": "session=caller",
"proxy-authorization": "Basic caller",
"x-api-key": "caller-key",
"x-request-id": "req-1",
},
),
(True, {"x-request-id": "req-1"}),
],
)
def test_convention_headers_drop_credentials_only_when_the_agent_opts_in(block_credentials, expected):
from litellm.proxy.agent_endpoints.a2a_endpoints import _convention_headers
headers: Final = {
"x-a2a-my-agent-authorization": "Bearer caller",
"x-a2a-my-agent-cookie": "session=caller",
"x-a2a-my-agent-proxy-authorization": "Basic caller",
"x-a2a-agent-123-x-api-key": "caller-key",
"x-a2a-my-agent-x-request-id": "req-1",
"x-a2a-other-agent-x-request-id": "not-for-this-agent",
"authorization": "Bearer sk-litellm-key",
}
forwarded: Final = _convention_headers(headers, ("agent-123", "my-agent"), block_credentials=block_credentials)
assert dict(forwarded) == expected
@pytest.mark.asyncio
async def test_agent_opted_in_never_receives_caller_credentials_via_convention():
mock_agent = _make_mock_agent()
mock_agent.agent_name = "my-agent"
mock_agent.litellm_params = {"block_convention_credential_headers": True}
mock_request = _make_mock_request(
extra_headers={
"x-a2a-my-agent-authorization": "Bearer forged-by-caller",
"x-a2a-my-agent-cookie": "session=forged-by-caller",
"x-a2a-my-agent-x-request-id": "req-abc",
}
)
mock_asend = await _invoke(mock_agent, mock_request, None)
assert mock_asend.call_args.kwargs.get("agent_extra_headers") == {
"x-request-id": "req-abc",
"X-LiteLLM-User-Id": "u1",
}
@pytest.mark.asyncio
async def test_agent_opted_in_still_sends_its_static_credentials():
mock_agent = _make_mock_agent(static_headers={"Authorization": "Bearer backend-static-secret"})
mock_agent.agent_name = "my-agent"
mock_agent.litellm_params = {"block_convention_credential_headers": True}
mock_request = _make_mock_request(extra_headers={"x-a2a-my-agent-authorization": "Bearer forged-by-caller"})
mock_asend = await _invoke(mock_agent, mock_request, None)
assert mock_asend.call_args.kwargs.get("agent_extra_headers") == {
"Authorization": "Bearer backend-static-secret",
"X-LiteLLM-User-Id": "u1",
}
# ---------------------------------------------------------------------------
# Databricks App OAuth M2M injection
# ---------------------------------------------------------------------------