From 341cf66ecfccbd8d723581300db3ba7a2425df33 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Sat, 22 Aug 2026 09:44:42 -0700 Subject: [PATCH 1/3] fix(guardrails): stop sending a non-Bedrock BYOK key to AWS as a bearer token The Bedrock guardrail read `request_data["api_key"]` and, when set, used it as the `Authorization: Bearer` token for its own ApplyGuardrail and InvokeGuardrailChecks calls instead of signing with SigV4. That field carries the key for the LLM call, not for the guardrail. With `enable_model_config_credential_overrides` on, `_apply_credential_overrides_from_model_config` writes the caller's own BYOK provider credential into `data["api_key"]` so the LLM call authenticates with it. A request that is both guardrailed and BYOK-routed therefore sent, say, an NVIDIA NIM or OpenRouter key to AWS: the guardrail call failed with a 403 even though the LLM call would have succeeded, and the caller's third-party secret left for a service it was never issued to. Only a Bedrock-routed request can carry a key that is also valid for ApplyGuardrail, so gate the override on that. The provider is read from `custom_llm_provider` where the request has it, the same way the straiker guardrail does it, and falls back to the model prefix. When the provider cannot be determined the previous behaviour is kept, so a clientside Bedrock key still works. Fixes #37872 --- .../guardrail_hooks/bedrock_guardrails.py | 31 ++++++- .../test_bedrock_guardrails.py | 80 +++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index c70a2ee8a74..6e5721faf11 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -705,6 +705,31 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ) return credentials, aws_region_name + @staticmethod + def _request_api_key_is_a_bedrock_credential(request_data: dict | None) -> bool: + """Whether ``request_data["api_key"]`` can be a Bedrock credential at all. + + That field holds the key for the **LLM** call, not for this guardrail's own + ApplyGuardrail request. Credential-override routing + (``_apply_credential_overrides_from_model_config``) writes the caller's BYOK + provider key into it, so on a non-Bedrock deployment it is someone else's + secret: signing with it fails, and it would be sent to AWS as a bearer token. + + Only a request that is itself Bedrock-routed can carry a key that is also + valid here. When the provider cannot be determined the old behaviour is kept, + so a clientside Bedrock key keeps working. + """ + if not request_data: + return False + provider = request_data.get("custom_llm_provider") or (request_data.get("litellm_params") or {}).get( + "custom_llm_provider" + ) + if provider: + return provider == "bedrock" + model: Final[str] = request_data.get("model") or "" + # A bare name gives nothing to route on, so leave it to the caller's config. + return "/" not in model or model.startswith("bedrock/") + def _prepare_request( self, credentials, @@ -851,7 +876,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): if key not in _BEDROCK_DYNAMIC_BODY_DENYLIST } ) - if request_data.get("api_key") is not None: + if request_data.get("api_key") is not None and self._request_api_key_is_a_bedrock_credential(request_data): api_key = request_data["api_key"] event_type: Final = ( @@ -1828,7 +1853,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): credentials, aws_region_name = self._load_credentials() body: Final[dict[str, Any]] = {"messages": checks_messages, "checks": self.checks} - api_key: Final[str | None] = request_data.get("api_key") if request_data else None + api_key: Final[str | None] = ( + request_data.get("api_key") if self._request_api_key_is_a_bedrock_credential(request_data) else None + ) prepared_request: Final = self._prepare_request( credentials=credentials, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index dd339d4e51f..1c70d6a2757 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -3,6 +3,7 @@ Unit tests for Bedrock Guardrails """ import json +import os import sys from unittest.mock import AsyncMock, MagicMock, patch @@ -10,6 +11,7 @@ import httpx import pytest from fastapi import HTTPException +sys.path.insert(0, os.path.abspath("../../../../../..")) import litellm from litellm.caching.caching import DualCache @@ -5274,3 +5276,81 @@ async def test_terminal_failure_logs_usage_and_cost_of_prior_passed_chunks(monke assert logged["guardrail_cost"] == pytest.approx(0.0003) assert logged["guardrail_response"]["usage"] == {"contentPolicyUnits": 2, "wordPolicyUnits": 1} assert "error" in logged["guardrail_response"] + + +class TestRequestApiKeyIsNotAlwaysABedrockCredential: + """request_data["api_key"] is the LLM call's key, not this guardrail's. + + Credential-override routing writes the caller's BYOK provider key into that + field, so on a non-Bedrock deployment using it as an ApplyGuardrail bearer + token both fails signing with a 403 and sends the caller's third-party secret + to AWS (issue #37872). + """ + + @staticmethod + def _guardrail(): + return BedrockGuardrail(guardrailIdentifier="gid", guardrailVersion="DRAFT") + + @pytest.mark.parametrize( + "request_data, expected", + [ + # BYOK credential override on a non-Bedrock deployment: never a Bedrock key. + ({"model": "nvidia_nim/meta/llama-3.1-8b", "api_key": "nvapi-xxx"}, False), + ({"model": "openrouter/anthropic/claude-3", "api_key": "sk-or-xxx"}, False), + ({"custom_llm_provider": "nvidia_nim", "model": "meta/llama", "api_key": "nvapi-xxx"}, False), + # Bedrock-routed requests keep the existing clientside-key behaviour. + ({"model": "bedrock/anthropic.claude-3", "api_key": "bedrock-key"}, True), + ({"custom_llm_provider": "bedrock", "model": "anthropic.claude-3", "api_key": "k"}, True), + ({"litellm_params": {"custom_llm_provider": "bedrock"}, "model": "x/y", "api_key": "k"}, True), + # Nothing to route on: unchanged, so a clientside Bedrock key still works. + ({"model": "claude-sonnet", "api_key": "k"}, True), + ({"api_key": "k"}, True), + # No request data at all. + (None, False), + ({}, False), + ], + ) + def test_only_a_bedrock_routed_request_yields_a_bedrock_credential(self, request_data, expected): + assert self._guardrail()._request_api_key_is_a_bedrock_credential(request_data) is expected + + def test_byok_key_is_not_sent_to_aws_as_a_bearer_token(self): + """End to end through _prepare_request: the NIM key must not become Authorization.""" + guardrail = self._guardrail() + credentials = MagicMock() + credentials.access_key, credentials.secret_key, credentials.token = "ak", "sk", None + request_data = {"model": "nvidia_nim/meta/llama-3.1-8b", "api_key": "nvapi-SECRET"} + + api_key = request_data["api_key"] if guardrail._request_api_key_is_a_bedrock_credential(request_data) else None + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("AWS_BEARER_TOKEN_BEDROCK", None) + prepped = guardrail._prepare_request( + credentials=credentials, + data={"source": "INPUT", "content": [{"text": {"text": "hi"}}]}, + optional_params={}, + aws_region_name="us-east-1", + api_key=api_key, + ) + + assert "nvapi-SECRET" not in str(dict(prepped.headers)) + assert prepped.headers.get("Authorization", "") != "Bearer nvapi-SECRET" + + def test_an_explicit_bedrock_key_still_becomes_the_bearer_token(self): + """Control: _prepare_request is untouched, so it passes with or without the fix. + + Written against _prepare_request directly rather than through the new helper, + so it proves the capability the api_key field exists for is not regressed. + """ + guardrail = self._guardrail() + credentials = MagicMock() + credentials.access_key, credentials.secret_key, credentials.token = "ak", "sk", None + + api_key = "bedrock-KEY" + prepped = guardrail._prepare_request( + credentials=credentials, + data={"source": "INPUT", "content": [{"text": {"text": "hi"}}]}, + optional_params={}, + aws_region_name="us-east-1", + api_key=api_key, + ) + + assert prepped.headers.get("Authorization") == "Bearer bedrock-KEY" From 157eab7e90d03122bbae95d4655f5e743fd12d9d Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Tue, 25 Aug 2026 12:39:21 -0700 Subject: [PATCH 2/3] chore(guardrails): take a Mapping in _request_api_key_is_a_bedrock_credential The type-discipline gate flagged the new `request_data: dict | None` annotation as LIT001 (mutable collection in an annotation). The helper only reads through `.get()`, so `Mapping[str, Any] | None` is both accurate and what the rule wants; the file already imports `Mapping` and annotates `base_request_data` that way. No suppression needed and both call sites pass a `dict`, which satisfies it. --- litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 6e5721faf11..a4618c9ef79 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -706,7 +706,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return credentials, aws_region_name @staticmethod - def _request_api_key_is_a_bedrock_credential(request_data: dict | None) -> bool: + def _request_api_key_is_a_bedrock_credential(request_data: Mapping[str, Any] | None) -> bool: """Whether ``request_data["api_key"]`` can be a Bedrock credential at all. That field holds the key for the **LLM** call, not for this guardrail's own From 0d49085cf1570e88d3ae6a9b85e1874e9312a6e8 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Tue, 25 Aug 2026 13:03:41 -0700 Subject: [PATCH 3/3] chore(tests): drop the sys.path.insert this branch still carried Upstream removed the `sys.path.insert(...)` line from this file; the branch predated that and kept it through the merge, which is the TQ003 the gate is reporting. Removed to match staging. `import os` stays, it is still used by the AWS_BEARER_TOKEN_BEDROCK environment test. --- .../proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 1c70d6a2757..7976c47ab1c 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -11,8 +11,6 @@ import httpx import pytest from fastapi import HTTPException -sys.path.insert(0, os.path.abspath("../../../../../..")) - import litellm from litellm.caching.caching import DualCache from litellm.exceptions import ModifyResponseException