From d8969d86f69b505daf020fff744c1eb406465fda Mon Sep 17 00:00:00 2001 From: ajitsharmas2007 Date: Wed, 30 Sep 2026 18:13:50 +0530 Subject: [PATCH] fix(proxy): only let a request-reading guardrail gate a non-object body `collect_guardrails` returns names and nothing else, so refusing whenever a passthrough route had any guardrail configured also turned away routes whose only guardrail is `post_call` or `logging_only`. Those inspect the response, never the request body, so a body they were never going to read is no reason to reject the request. Filter the collected names by event mode and refuse only when one of them actually reads the request. `during_call` counts alongside `pre_call` for that; `post_call` and `logging_only` do not. A configured name with no initialized callback inspects nothing either, since there is nothing to run. The event-mode question is answered by the guardrail itself via `_event_hook_is_event_type`, the same private predicate common_request_processing.py uses, rather than re-deriving rules that handle None, a single hook, a list of hooks and the enterprise Mode form. The round's tests passed a guardrail name with nothing behind it, which proves nothing once the filter exists, so they now register real CustomGuardrail instances through litellm.callbacks. --- .../pass_through_endpoints.py | 13 +++-- .../passthrough_guardrails.py | 32 +++++++++++ .../test_pass_through_endpoints.py | 55 ++++++++++++++++--- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index d21f64157ff..d671d28d7e2 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1143,10 +1143,15 @@ async def pass_through_request( passthrough_guardrails_config=guardrails_config, ) - if guardrails_to_run and uninspectable_body is not None: - # Guardrails read the parsed body, which for a non-object payload carries none of - # the caller's content, while the bytes forwarded upstream carry all of it. Running - # them would report "inspected" on content nobody looked at, so refuse instead. + if ( + guardrails_to_run + and uninspectable_body is not None + and PassthroughGuardrailHandler.any_inspects_the_request_body(guardrails_to_run) + ): + # A request-inspecting guardrail reads the parsed body, which for a non-object + # payload carries none of the caller's content, while the bytes forwarded upstream + # carry all of it. Running it would report "inspected" on content nobody looked at, + # so refuse instead. A post_call guardrail reads the response, so it does not gate. raise ProxyException( message=( "Guardrails are configured for this route and cannot inspect a JSON body " diff --git a/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py b/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py index de9b0acf081..6f219e0ae9f 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py @@ -7,15 +7,37 @@ Handles guardrail execution for passthrough endpoints with: - Automatic inheritance from org/team/key levels when enabled """ +from collections.abc import Collection from typing import Any, Final +import litellm from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import ( PassThroughGuardrailsConfig, PassThroughGuardrailSettings, UserAPIKeyAuth, ) from litellm.proxy.pass_through_endpoints.jsonpath_extractor import JsonPathExtractor +from litellm.types.guardrails import GuardrailEventHooks + +# The hooks that read the request body. ``post_call`` and ``logging_only`` read the response. +_REQUEST_INSPECTING_EVENT_HOOKS: Final = ( + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, +) + + +def _reads_the_request_body(callback: object, guardrail_names: Collection[str]) -> bool: + if not isinstance(callback, CustomGuardrail) or callback.guardrail_name not in guardrail_names: + return False + # Same private predicate common_request_processing.py uses to ask a callback which + # lifecycle hooks it is configured for, rather than re-deriving the matching rules. + return any( + callback._event_hook_is_event_type(hook) # pyright: ignore[reportPrivateUsage] # no public equivalent + for hook in _REQUEST_INSPECTING_EVENT_HOOKS + ) + # Type for raw guardrails config input (before normalization) # Can be a list of names or a dict with settings @@ -273,6 +295,16 @@ class PassthroughGuardrailHandler: return guardrails_to_run if guardrails_to_run else None + @staticmethod + def any_inspects_the_request_body(guardrail_names: Collection[str]) -> bool: + """Whether any of these guardrails reads the request body. + + A ``post_call`` or ``logging_only`` guardrail inspects the response, so a request + body it never reads is no reason to turn the request away. A name with no + initialized callback inspects nothing either, since there is nothing to run. + """ + return any(_reads_the_request_body(callback, guardrail_names) for callback in litellm.callbacks) + @staticmethod def get_field_targeted_text( data: dict, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index ee008dc8e9e..57859e27bc9 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -24,6 +24,7 @@ from starlette.datastructures import UploadFile as StarletteUploadFile import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import ProxyException, UserAPIKeyAuth @@ -49,6 +50,7 @@ from litellm.proxy.pass_through_endpoints.success_handler import ( ) from litellm.proxy.route_llm_request import ProxyModelNotFoundError from litellm.types import utils as types_utils +from litellm.types.guardrails import GuardrailEventHooks from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, @@ -7771,6 +7773,18 @@ async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): return error.value +@contextmanager +def _registered_guardrail(name: str, mode: GuardrailEventHooks): + """Register a real guardrail the way the proxy does, so the code under test resolves its + event mode instead of being told the answer.""" + guardrail = CustomGuardrail(guardrail_name=name, event_hook=mode) + litellm.callbacks.append(guardrail) + try: + yield + finally: + litellm.callbacks.remove(guardrail) + + @pytest.mark.asyncio @pytest.mark.parametrize( "body", @@ -7780,17 +7794,37 @@ async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): pytest.param(b"123", id="number"), ], ) -async def test_guarded_route_refuses_a_body_its_guardrails_cannot_read(body: bytes): - """Guardrails inspect the parsed body, which for a non-object payload carries none of the - caller's content, while the bytes forwarded upstream carry all of it. Running them would - report "inspected" on content nobody looked at, so the request must be refused instead.""" - raised = await _run_guarded_passthrough(body, guardrails=["my-guard"]) +async def test_route_with_a_request_guardrail_refuses_a_body_it_cannot_read(body: bytes): + """A pre_call guardrail inspects the parsed body, which for a non-object payload carries + none of the caller's content, while the bytes forwarded upstream carry all of it. Running + it would report "inspected" on content nobody looked at, so refuse instead.""" + with _registered_guardrail("precall-guard", GuardrailEventHooks.pre_call): + raised = await _run_guarded_passthrough(body, guardrails=["precall-guard"]) assert raised.code == "400" assert raised.type == "invalid_request_error" assert "cannot inspect a JSON body that is not an object" in raised.message +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mode", + [ + pytest.param(GuardrailEventHooks.post_call, id="post_call"), + pytest.param(GuardrailEventHooks.logging_only, id="logging_only"), + ], +) +async def test_a_response_only_guardrail_does_not_gate_the_request_body(mode: GuardrailEventHooks): + """A guardrail that inspects the response never reads the request body, so a body it was + never going to look at is no reason to turn the request away.""" + body = b'[{"role": "user", "content": "hi"}]' + + with _registered_guardrail("response-guard", mode): + upstream = await _capture_upstream_request(body, guardrails=["response-guard"]) + + assert upstream.content == body + + @pytest.mark.asyncio async def test_unguarded_route_still_forwards_a_non_object_body(): """The refusal is scoped to routes that actually configured guardrails: passthrough is @@ -7801,10 +7835,13 @@ async def test_unguarded_route_still_forwards_a_non_object_body(): @pytest.mark.asyncio -async def test_guarded_route_accepts_an_object_body(): - """An object body is fully readable by guardrails, so guardrails being configured must not - turn it away: it still reaches the provider.""" - upstream = await _capture_upstream_request(b'{"model": "claude-sonnet-4-5"}', guardrails=["my-guard"]) +async def test_a_request_guardrail_still_accepts_an_object_body(): + """An object body is fully readable, so a pre_call guardrail being configured must not turn + it away: it still reaches the provider.""" + with _registered_guardrail("precall-guard", GuardrailEventHooks.pre_call): + upstream = await _capture_upstream_request( + b'{"model": "claude-sonnet-4-5"}', guardrails=["precall-guard"] + ) assert json.loads(upstream.content)["model"] == "claude-sonnet-4-5"