fix(guardrails): reserve the identity fields that live outside the key namespace

Attribution is not only the user_api_key_ namespace: pre-call processing also fills in the caller's ip address, user agent and agent id, and a caller could set those on /apply_guardrail and have a guardrail attribute the call to someone else. Reserve them by name alongside the namespace.
This commit is contained in:
Alessandro Pignati 2026-11-02 00:07:50 +01:00
parent a5827b3a4e
commit b1da2511cc
2 changed files with 35 additions and 6 deletions

View file

@ -2349,18 +2349,25 @@ def _metadata_fields(value: object) -> Mapping[str, object]:
_CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id", "user_api_key_request_route"})
_RESERVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset(
{"requester_ip_address", "user_agent", "agent_id", "actor_agent_id"}
)
def _is_key_identity_field(key: str) -> bool:
"""Whether a metadata field attributes the authenticated key rather than the request.
"""Whether a metadata field attributes the authenticated caller rather than the request.
The ``user_api_key_`` namespace also carries two fields that describe the caller's own
request: the end user it is made for, and the route it came from. A guardrail needs both
to decide, and on ``/apply_guardrail`` the proxy's own values say nothing about the call
the caller is asking about.
Pre-call processing owns the whole ``user_api_key_`` namespace, except the two fields that
describe the caller's own request: the end user it is made for, and the route it came from.
A guardrail needs both to decide, and on ``/apply_guardrail`` the proxy's own values say
nothing about the call the caller is asking about.
Attribution also reaches outside that namespace, so the fields pre-call processing fills in
from the key and the connection are reserved by name as well.
"""
if key in _CALLER_OWNED_KEY_FIELDS:
return False
return key == "user_api_key" or key.startswith("user_api_key_")
return key in _RESERVED_IDENTITY_FIELDS or key == "user_api_key" or key.startswith("user_api_key_")
def _guardrail_request_metadata(

View file

@ -2515,6 +2515,28 @@ class TestGuardrailRequestMetadata:
"user_api_key_request_route": "/v1/chat/completions",
}
def test_identity_fields_outside_the_key_namespace_are_reserved_too(self) -> None:
"""The proxy sets who is calling and from where. A caller cannot claim another's."""
metadata: Final = _guardrail_request_metadata(
{
"requester_ip_address": "203.0.113.9",
"user_agent": "forged/1.0",
"agent_id": "someone-elses-agent",
"topic": "tax",
},
{
"requester_ip_address": "198.51.100.4",
"user_agent": "litellm-cli/2.3",
"agent_id": "billing-agent",
},
)
assert metadata == {
"topic": "tax",
"requester_ip_address": "198.51.100.4",
"user_agent": "litellm-cli/2.3",
"agent_id": "billing-agent",
}
def test_a_guardrail_can_annotate_what_it_is_handed(self) -> None:
"""OpenAI moderation and Presidio write their detection details into this mapping."""
metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"})