From b1da2511cca2965c5f6ea00d37561d660cc5fae2 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Mon, 2 Nov 2026 00:07:50 +0100 Subject: [PATCH] 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. --- .../proxy/guardrails/guardrail_endpoints.py | 19 +++++++++++----- .../guardrails/test_guardrail_endpoints.py | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 20dd66b92b4..2b3d10350d1 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -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( diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index b5069cae4b3..836f68d5626 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -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"})