From 9d69fdac72b860627f627f9802ceb4758a7c372d Mon Sep 17 00:00:00 2001 From: daleselaji-dev <265319989+daleselaji-dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:14:56 +0800 Subject: [PATCH 1/7] fix(bedrock): use deployment credentials for AWS requests --- .../llms/bedrock/batches/transformation.py | 11 ++- litellm/llms/bedrock/files/transformation.py | 11 ++- .../test_bedrock_request_credentials.py | 95 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 tests/litellm/llms/bedrock/test_bedrock_request_credentials.py diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 9e3dec26673..8b437af8581 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -130,7 +130,8 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): Get the complete URL for Bedrock batch creation. Bedrock batch jobs are created via the model invocation job API. """ - aws_region_name: Final = self._get_aws_region_name(optional_params, model) + request_params: Final = {**litellm_params, **optional_params} + aws_region_name: Final = self._get_aws_region_name(request_params, model) # Bedrock model invocation job endpoint # Format: https://bedrock.{region}.amazonaws.com/model-invocation-job @@ -232,14 +233,15 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): # For Bedrock, we need to return a pre-signed request with AWS auth headers # Use common utility for AWS signing + request_params: Final = {**litellm_params, **optional_params} endpoint_url: Final = ( - f"https://bedrock.{self._get_aws_region_name(optional_params, model)}.amazonaws.com/model-invocation-job" + f"https://bedrock.{self._get_aws_region_name(request_params, model)}.amazonaws.com/model-invocation-job" ) signed_headers, signed_data = self.common_utils.sign_aws_request( service_name="bedrock", data=bedrock_request, endpoint_url=endpoint_url, - optional_params=optional_params, + optional_params=request_params, method="POST", ) @@ -387,11 +389,12 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): endpoint_url: Final = f"https://bedrock.{region}.amazonaws.com/model-invocation-job/{encoded_arn}" # Use common utility for AWS signing + request_params: Final = {**litellm_params, **optional_params} signed_headers, _ = self.common_utils.sign_aws_request( service_name="bedrock", data={}, # GET request has no body endpoint_url=endpoint_url, - optional_params=optional_params, + optional_params=request_params, method="GET", ) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 728fd02f001..072343ae936 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -257,6 +257,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): """ Get the complete S3 URL for the file upload request """ + request_params: Final = {**litellm_params, **optional_params} bucket_name = litellm_params.get("s3_bucket_name") or os.getenv("AWS_S3_BUCKET_NAME") if not bucket_name: raise ValueError( @@ -265,7 +266,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): bucket_name, object_prefix = split_configured_cloud_bucket_name(bucket_name) s3_region_name: Final = litellm_params.get("s3_region_name") or optional_params.get("s3_region_name") - aws_region_name: Final = s3_region_name or self._get_aws_region_name(optional_params, model) + aws_region_name: Final = s3_region_name or self._get_aws_region_name(request_params, model) file_data: Final = data.get("file") purpose: Final = data.get("purpose") @@ -281,7 +282,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): # S3 endpoint URL format s3_endpoint_url: Final = ( - optional_params.get("s3_endpoint_url") or f"https://s3.{aws_region_name}.amazonaws.com" + request_params.get("s3_endpoint_url") or f"https://s3.{aws_region_name}.amazonaws.com" ).rstrip("/") return f"{s3_endpoint_url}/{bucket_name}/{encoded_object_name}" @@ -728,14 +729,16 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): if s3_region_name: optional_params = {**optional_params, "aws_region_name": s3_region_name} + request_params: Final = {**litellm_params, **optional_params} + # Sign the request and return a pre-signed request object signed_headers, signed_body = self._sign_s3_request( content=file_content, api_base=api_base, - optional_params=optional_params, + optional_params=request_params, s3_encryption_key_id=resolve_s3_encryption_key_id( litellm_params=litellm_params, - optional_params=optional_params, + optional_params=request_params, ), ) diff --git a/tests/litellm/llms/bedrock/test_bedrock_request_credentials.py b/tests/litellm/llms/bedrock/test_bedrock_request_credentials.py new file mode 100644 index 00000000000..72da2daf9b5 --- /dev/null +++ b/tests/litellm/llms/bedrock/test_bedrock_request_credentials.py @@ -0,0 +1,95 @@ +from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig +from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + +def test_bedrock_file_upload_signing_uses_deployment_credentials(monkeypatch): + config = BedrockFilesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, "" + + monkeypatch.setattr(config, "_sign_s3_request", capture_signing) + + result = config.transform_create_file_request( + model="", + create_file_data={ + "file": ( + "batch.jsonl", + b'{"custom_id":"req-1","body":{"model":"bedrock/model"}}\n', + "application/jsonl", + ), + "purpose": "batch", + }, + optional_params={}, + litellm_params={ + "s3_bucket_name": "deployment-bucket", + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + }, + ) + + assert "eu-west-1" in result["url"] + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" + assert captured["optional_params"]["aws_region_name"] == "eu-west-1" + + +def test_bedrock_batch_signing_uses_deployment_credentials(monkeypatch): + config = BedrockBatchesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, b"{}" + + monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) + + result = config.transform_create_batch_request( + model="us.anthropic.claude-haiku-4-5-20251001-v1:0", + create_batch_data={ + "input_file_id": "s3://deployment-bucket/input.jsonl", + "completion_window": "24h", + "endpoint": "/v1/chat/completions", + }, + optional_params={}, + litellm_params={ + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + "aws_batch_role_arn": "arn:aws:iam::123456789012:role/bedrock-batch", + }, + ) + + assert result["url"].startswith("https://bedrock.eu-west-1.amazonaws.com/") + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" + assert captured["optional_params"]["aws_region_name"] == "eu-west-1" + + +def test_bedrock_batch_retrieval_signing_uses_deployment_credentials(monkeypatch): + config = BedrockBatchesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, b"" + + monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) + + result = config.transform_retrieve_batch_request( + batch_id="arn:aws:bedrock:eu-west-1:123456789012:model-invocation-job/job-1", + optional_params={}, + litellm_params={ + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + }, + ) + + assert result["url"].startswith("https://bedrock.eu-west-1.amazonaws.com/") + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" + assert captured["optional_params"]["aws_region_name"] == "eu-west-1" From b11d342022e1dfe88ad05438ecab49524173028a Mon Sep 17 00:00:00 2001 From: daleselaji-dev <265319989+daleselaji-dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:40:17 +0800 Subject: [PATCH 2/7] chore(bedrock): document mutable signing params --- litellm/llms/bedrock/batches/transformation.py | 15 ++++++++++++--- litellm/llms/bedrock/files/transformation.py | 10 ++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index 8b437af8581..e92a2e7a00a 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -130,7 +130,10 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): Get the complete URL for Bedrock batch creation. Bedrock batch jobs are created via the model invocation job API. """ - request_params: Final = {**litellm_params, **optional_params} + request_params: Final = { + **litellm_params, + **optional_params, + } # mutable-ok: merged params are read by AWS helpers aws_region_name: Final = self._get_aws_region_name(request_params, model) # Bedrock model invocation job endpoint @@ -233,7 +236,10 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): # For Bedrock, we need to return a pre-signed request with AWS auth headers # Use common utility for AWS signing - request_params: Final = {**litellm_params, **optional_params} + request_params: Final = { + **litellm_params, + **optional_params, + } # mutable-ok: merged params are read by AWS helpers endpoint_url: Final = ( f"https://bedrock.{self._get_aws_region_name(request_params, model)}.amazonaws.com/model-invocation-job" ) @@ -389,7 +395,10 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): endpoint_url: Final = f"https://bedrock.{region}.amazonaws.com/model-invocation-job/{encoded_arn}" # Use common utility for AWS signing - request_params: Final = {**litellm_params, **optional_params} + request_params: Final = { + **litellm_params, + **optional_params, + } # mutable-ok: merged params are read by AWS helpers signed_headers, _ = self.common_utils.sign_aws_request( service_name="bedrock", data={}, # GET request has no body diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 072343ae936..cd4f52ddbfe 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -257,7 +257,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): """ Get the complete S3 URL for the file upload request """ - request_params: Final = {**litellm_params, **optional_params} + request_params: Final = { + **litellm_params, + **optional_params, + } # mutable-ok: merged params are read by AWS helpers bucket_name = litellm_params.get("s3_bucket_name") or os.getenv("AWS_S3_BUCKET_NAME") if not bucket_name: raise ValueError( @@ -729,7 +732,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): if s3_region_name: optional_params = {**optional_params, "aws_region_name": s3_region_name} - request_params: Final = {**litellm_params, **optional_params} + request_params: Final = { + **litellm_params, + **optional_params, + } # mutable-ok: merged params are read by AWS helpers # Sign the request and return a pre-signed request object signed_headers, signed_body = self._sign_s3_request( From 8f998a9ca477942815b525656bbc6f52a9fcecef Mon Sep 17 00:00:00 2001 From: daleselaji-dev <265319989+daleselaji-dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:45:06 +0800 Subject: [PATCH 3/7] fix(bedrock): prevent caller AWS identity override --- .../llms/bedrock/batches/transformation.py | 21 ++- litellm/llms/bedrock/common_utils.py | 38 +++++ litellm/llms/bedrock/files/transformation.py | 12 +- .../test_bedrock_files_and_batches.py | 130 ++++++++++++++++++ .../test_bedrock_request_credentials.py | 95 ------------- 5 files changed, 179 insertions(+), 117 deletions(-) delete mode 100644 tests/litellm/llms/bedrock/test_bedrock_request_credentials.py diff --git a/litellm/llms/bedrock/batches/transformation.py b/litellm/llms/bedrock/batches/transformation.py index e92a2e7a00a..04f395f2bf1 100644 --- a/litellm/llms/bedrock/batches/transformation.py +++ b/litellm/llms/bedrock/batches/transformation.py @@ -28,7 +28,11 @@ from litellm.types.llms.openai import ( from litellm.types.utils import LiteLLMBatch, LlmProviders from ..base_aws_llm import BaseAWSLLM -from ..common_utils import CommonBatchFilesUtils, resolve_s3_encryption_key_id +from ..common_utils import ( + CommonBatchFilesUtils, + merge_bedrock_aws_request_params, + resolve_s3_encryption_key_id, +) # Bedrock batch input files are uploaded as # s3://bucket/litellm-bedrock-files-{model, ":" -> "-"}-{uuid4}.jsonl (see @@ -130,10 +134,7 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): Get the complete URL for Bedrock batch creation. Bedrock batch jobs are created via the model invocation job API. """ - request_params: Final = { - **litellm_params, - **optional_params, - } # mutable-ok: merged params are read by AWS helpers + request_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) aws_region_name: Final = self._get_aws_region_name(request_params, model) # Bedrock model invocation job endpoint @@ -236,10 +237,7 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): # For Bedrock, we need to return a pre-signed request with AWS auth headers # Use common utility for AWS signing - request_params: Final = { - **litellm_params, - **optional_params, - } # mutable-ok: merged params are read by AWS helpers + request_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) endpoint_url: Final = ( f"https://bedrock.{self._get_aws_region_name(request_params, model)}.amazonaws.com/model-invocation-job" ) @@ -395,10 +393,7 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig): endpoint_url: Final = f"https://bedrock.{region}.amazonaws.com/model-invocation-job/{encoded_arn}" # Use common utility for AWS signing - request_params: Final = { - **litellm_params, - **optional_params, - } # mutable-ok: merged params are read by AWS helpers + request_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) signed_headers, _ = self.common_utils.sign_aws_request( service_name="bedrock", data={}, # GET request has no body diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index d18cb7d8734..57202f2d626 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -36,6 +36,44 @@ class BedrockError(BaseLLMException): pass +_BEDROCK_AWS_AUTH_PARAMETER_KEYS: Final[tuple[str, ...]] = ( + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_region_name", + "aws_session_name", + "aws_profile_name", + "aws_role_name", + "aws_web_identity_token", + "aws_sts_endpoint", + "aws_external_id", +) + + +def merge_bedrock_aws_request_params( + litellm_params: Mapping[str, Any], + optional_params: Mapping[str, Any], +) -> dict[str, Any]: + """Merge deployment and request parameters without allowing auth escalation. + + Deployment configuration is authoritative for AWS authentication. When a + deployment supplies static credentials, caller-supplied profile/role/token + selectors must not redirect signing to another identity available on the + server. Requests may still provide AWS credentials when the deployment has + no static credentials configured. + """ + request_params: Final = {**optional_params, **litellm_params} # mutable-ok: AWS helpers require a plain dict + has_static_deployment_credentials = all( + isinstance(litellm_params.get(key), str) and bool(litellm_params.get(key)) + for key in ("aws_access_key_id", "aws_secret_access_key", "aws_region_name") + ) + if has_static_deployment_credentials: + for key in _BEDROCK_AWS_AUTH_PARAMETER_KEYS: + if key not in litellm_params: + request_params.pop(key, None) + return request_params + + # Lazy import cache to avoid circular imports and performance impact _get_model_info = None diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index cd4f52ddbfe..7663105ba4a 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -46,7 +46,7 @@ from litellm.types.utils import ExtractedFileData, LlmProviders, SpecialEnums from litellm.utils import get_llm_provider from ..base_aws_llm import BaseAWSLLM -from ..common_utils import BedrockError, resolve_s3_encryption_key_id +from ..common_utils import BedrockError, merge_bedrock_aws_request_params, resolve_s3_encryption_key_id # litellm_params key used to hand the SigV4-signed GET headers from # `transform_file_content_request` to `validate_environment` (the only hook @@ -257,10 +257,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): """ Get the complete S3 URL for the file upload request """ - request_params: Final = { - **litellm_params, - **optional_params, - } # mutable-ok: merged params are read by AWS helpers + request_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) bucket_name = litellm_params.get("s3_bucket_name") or os.getenv("AWS_S3_BUCKET_NAME") if not bucket_name: raise ValueError( @@ -732,10 +729,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): if s3_region_name: optional_params = {**optional_params, "aws_region_name": s3_region_name} - request_params: Final = { - **litellm_params, - **optional_params, - } # mutable-ok: merged params are read by AWS helpers + request_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) # Sign the request and return a pre-signed request object signed_headers, signed_body = self._sign_s3_request( diff --git a/tests/batches_tests/test_bedrock_files_and_batches.py b/tests/batches_tests/test_bedrock_files_and_batches.py index 431d5a2a60c..52e88937916 100644 --- a/tests/batches_tests/test_bedrock_files_and_batches.py +++ b/tests/batches_tests/test_bedrock_files_and_batches.py @@ -389,3 +389,133 @@ def test_bedrock_batch_with_encryption_key_in_post_request(): ) print("SUCCESS: s3_encryption_key_id properly included in AWS POST request") + + +def test_bedrock_file_upload_signing_uses_deployment_credentials(monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, "" + + monkeypatch.setattr(config, "_sign_s3_request", capture_signing) + + result = config.transform_create_file_request( + model="", + create_file_data={ + "file": ( + "batch.jsonl", + b'{"custom_id":"req-1","body":{"model":"bedrock/model"}}\n', + "application/jsonl", + ), + "purpose": "batch", + }, + optional_params={}, + litellm_params={ + "s3_bucket_name": "deployment-bucket", + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + }, + ) + + assert "eu-west-1" in result["url"] + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" + assert captured["optional_params"]["aws_region_name"] == "eu-west-1" + + +def test_bedrock_batch_signing_uses_deployment_credentials(monkeypatch): + from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig + + config = BedrockBatchesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, b"{}" + + monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) + + result = config.transform_create_batch_request( + model="us.anthropic.claude-haiku-4-5-20251001-v1:0", + create_batch_data={ + "input_file_id": "s3://deployment-bucket/input.jsonl", + "completion_window": "24h", + "endpoint": "/v1/chat/completions", + }, + optional_params={}, + litellm_params={ + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + "aws_batch_role_arn": "arn:aws:iam::123456789012:role/bedrock-batch", + }, + ) + + assert result["url"].startswith("https://bedrock.eu-west-1.amazonaws.com/") + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" + assert captured["optional_params"]["aws_region_name"] == "eu-west-1" + + +def test_bedrock_batch_retrieval_signing_uses_deployment_credentials(monkeypatch): + from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig + + config = BedrockBatchesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, b"" + + monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) + + result = config.transform_retrieve_batch_request( + batch_id="arn:aws:bedrock:eu-west-1:123456789012:model-invocation-job/job-1", + optional_params={}, + litellm_params={ + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + }, + ) + + assert result["url"].startswith("https://bedrock.eu-west-1.amazonaws.com/") + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" + assert captured["optional_params"]["aws_region_name"] == "eu-west-1" + + +def test_bedrock_deployment_credentials_block_caller_profile_override(monkeypatch): + from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig + + config = BedrockBatchesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, b"{}" + + monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) + + config.transform_create_batch_request( + model="us.anthropic.claude-haiku-4-5-20251001-v1:0", + create_batch_data={ + "input_file_id": "s3://deployment-bucket/input.jsonl", + "completion_window": "24h", + }, + optional_params={"aws_profile_name": "caller-controlled-profile"}, + litellm_params={ + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "eu-west-1", + "aws_batch_role_arn": "arn:aws:iam::123456789012:role/bedrock-batch", + }, + ) + + assert "aws_profile_name" not in captured["optional_params"] + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" diff --git a/tests/litellm/llms/bedrock/test_bedrock_request_credentials.py b/tests/litellm/llms/bedrock/test_bedrock_request_credentials.py deleted file mode 100644 index 72da2daf9b5..00000000000 --- a/tests/litellm/llms/bedrock/test_bedrock_request_credentials.py +++ /dev/null @@ -1,95 +0,0 @@ -from litellm.llms.bedrock.batches.transformation import BedrockBatchesConfig -from litellm.llms.bedrock.files.transformation import BedrockFilesConfig - - -def test_bedrock_file_upload_signing_uses_deployment_credentials(monkeypatch): - config = BedrockFilesConfig() - captured = {} - - def capture_signing(**kwargs): - captured.update(kwargs) - return {}, "" - - monkeypatch.setattr(config, "_sign_s3_request", capture_signing) - - result = config.transform_create_file_request( - model="", - create_file_data={ - "file": ( - "batch.jsonl", - b'{"custom_id":"req-1","body":{"model":"bedrock/model"}}\n', - "application/jsonl", - ), - "purpose": "batch", - }, - optional_params={}, - litellm_params={ - "s3_bucket_name": "deployment-bucket", - "aws_access_key_id": "deployment-access-key", - "aws_secret_access_key": "deployment-secret", - "aws_region_name": "eu-west-1", - }, - ) - - assert "eu-west-1" in result["url"] - assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" - assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" - assert captured["optional_params"]["aws_region_name"] == "eu-west-1" - - -def test_bedrock_batch_signing_uses_deployment_credentials(monkeypatch): - config = BedrockBatchesConfig() - captured = {} - - def capture_signing(**kwargs): - captured.update(kwargs) - return {}, b"{}" - - monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) - - result = config.transform_create_batch_request( - model="us.anthropic.claude-haiku-4-5-20251001-v1:0", - create_batch_data={ - "input_file_id": "s3://deployment-bucket/input.jsonl", - "completion_window": "24h", - "endpoint": "/v1/chat/completions", - }, - optional_params={}, - litellm_params={ - "aws_access_key_id": "deployment-access-key", - "aws_secret_access_key": "deployment-secret", - "aws_region_name": "eu-west-1", - "aws_batch_role_arn": "arn:aws:iam::123456789012:role/bedrock-batch", - }, - ) - - assert result["url"].startswith("https://bedrock.eu-west-1.amazonaws.com/") - assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" - assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" - assert captured["optional_params"]["aws_region_name"] == "eu-west-1" - - -def test_bedrock_batch_retrieval_signing_uses_deployment_credentials(monkeypatch): - config = BedrockBatchesConfig() - captured = {} - - def capture_signing(**kwargs): - captured.update(kwargs) - return {}, b"" - - monkeypatch.setattr(config.common_utils, "sign_aws_request", capture_signing) - - result = config.transform_retrieve_batch_request( - batch_id="arn:aws:bedrock:eu-west-1:123456789012:model-invocation-job/job-1", - optional_params={}, - litellm_params={ - "aws_access_key_id": "deployment-access-key", - "aws_secret_access_key": "deployment-secret", - "aws_region_name": "eu-west-1", - }, - ) - - assert result["url"].startswith("https://bedrock.eu-west-1.amazonaws.com/") - assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" - assert captured["optional_params"]["aws_secret_access_key"] == "deployment-secret" - assert captured["optional_params"]["aws_region_name"] == "eu-west-1" From f72ddedf39c52bf113c6e3f2ece4c84930531b70 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:28:05 -0700 Subject: [PATCH 4/7] fix(bedrock): keep s3_region_name authoritative over merged deployment region --- litellm/llms/bedrock/common_utils.py | 2 +- litellm/llms/bedrock/files/transformation.py | 13 ++++--- .../test_bedrock_files_and_batches.py | 37 +++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 57202f2d626..48bc60a07e5 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -63,7 +63,7 @@ def merge_bedrock_aws_request_params( no static credentials configured. """ request_params: Final = {**optional_params, **litellm_params} # mutable-ok: AWS helpers require a plain dict - has_static_deployment_credentials = all( + has_static_deployment_credentials: Final = all( isinstance(litellm_params.get(key), str) and bool(litellm_params.get(key)) for key in ("aws_access_key_id", "aws_secret_access_key", "aws_region_name") ) diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index f50651f4cb7..4ff7323c33f 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -844,13 +844,14 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): ) # s3_region_name always wins for S3 operations (same priority as in - # get_complete_file_url above). Overwrite aws_region_name unconditionally - # so the SigV4 region matches the URL region, avoiding SignatureDoesNotMatch. + # get_complete_file_url above). Overwrite aws_region_name unconditionally, + # after the deployment-credential merge, so the SigV4 region matches the + # URL region, avoiding SignatureDoesNotMatch. + merged_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) s3_region_name: Final = litellm_params.get("s3_region_name") or optional_params.get("s3_region_name") - if s3_region_name: - optional_params = {**optional_params, "aws_region_name": s3_region_name} - - request_params: Final = merge_bedrock_aws_request_params(litellm_params, optional_params) + request_params: Final = ( + {**merged_params, "aws_region_name": s3_region_name} if s3_region_name else merged_params + ) # Sign the request and return a pre-signed request object signed_headers, signed_body = self._sign_s3_request( diff --git a/tests/batches_tests/test_bedrock_files_and_batches.py b/tests/batches_tests/test_bedrock_files_and_batches.py index 52e88937916..b9045cc43d6 100644 --- a/tests/batches_tests/test_bedrock_files_and_batches.py +++ b/tests/batches_tests/test_bedrock_files_and_batches.py @@ -519,3 +519,40 @@ def test_bedrock_deployment_credentials_block_caller_profile_override(monkeypatc assert "aws_profile_name" not in captured["optional_params"] assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" + + +def test_bedrock_file_upload_s3_region_survives_deployment_region_merge(monkeypatch): + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + captured = {} + + def capture_signing(**kwargs): + captured.update(kwargs) + return {}, "" + + monkeypatch.setattr(config, "_sign_s3_request", capture_signing) + + result = config.transform_create_file_request( + model="", + create_file_data={ + "file": ( + "batch.jsonl", + b'{"custom_id":"req-1","body":{"model":"bedrock/model"}}\n', + "application/jsonl", + ), + "purpose": "batch", + }, + optional_params={}, + litellm_params={ + "s3_bucket_name": "deployment-bucket", + "s3_region_name": "eu-central-1", + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "us-east-1", + }, + ) + + assert "s3.eu-central-1.amazonaws.com" in result["url"] + assert captured["optional_params"]["aws_region_name"] == "eu-central-1" + assert captured["optional_params"]["aws_access_key_id"] == "deployment-access-key" From b63ba63655ebf289ae5e297685784db93dec527d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:46:41 -0700 Subject: [PATCH 5/7] fix(router): preserve aws session token and role params in deployment credential resolution --- litellm/types/router.py | 7 ++++ tests/test_litellm/test_router.py | 40 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 28 +++++++++++++ 3 files changed, 75 insertions(+) diff --git a/litellm/types/router.py b/litellm/types/router.py index 4f8c133c20b..3ac59c0f581 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -237,7 +237,14 @@ class CredentialLiteLLMParams(BaseModel): ## AWS BEDROCK / SAGEMAKER ## aws_access_key_id: str | None = None aws_secret_access_key: str | None = None + aws_session_token: str | None = None aws_region_name: str | None = None + aws_session_name: str | None = None + aws_profile_name: str | None = None + aws_role_name: str | None = None + aws_web_identity_token: str | None = None + aws_sts_endpoint: str | None = None + aws_external_id: str | None = None aws_bedrock_runtime_endpoint: str | None = None aws_bedrock_project_id: str | None = None s3_bucket_name: str | None = None diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index d97d9515f08..0a16b998f82 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -4062,6 +4062,46 @@ def test_get_deployment_credentials_with_provider_bedrock_batch_fields(): assert credentials["aws_batch_role_arn"] == "arn:aws:iam::123:role/batch-role" +def test_get_deployment_credentials_with_provider_preserves_aws_auth_params(): + """ + Test that get_deployment_credentials_with_provider preserves every AWS auth + selector (session token, assume-role, web identity, profile) so bedrock + files/batches deployments using temporary or role-based credentials do not + silently fall back to the server's ambient identity (#36155). + """ + aws_auth_params = { + "aws_access_key_id": "deployment-access-key", + "aws_secret_access_key": "deployment-secret", + "aws_session_token": "deployment-session-token", + "aws_region_name": "us-west-2", + "aws_session_name": "deployment-session", + "aws_profile_name": "deployment-profile", + "aws_role_name": "arn:aws:iam::123:role/deployment-role", + "aws_web_identity_token": "deployment-web-identity", + "aws_sts_endpoint": "https://sts.us-west-2.amazonaws.com", + "aws_external_id": "deployment-external-id", + } + router = litellm.Router( + model_list=[ + { + "model_name": "bedrock-batch-model", + "litellm_params": { + "model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", + **aws_auth_params, + }, + } + ], + ) + + credentials = router.get_deployment_credentials_with_provider( + model_id="bedrock-batch-model" + ) + + assert credentials is not None + for key, value in aws_auth_params.items(): + assert credentials.get(key) == value, key + + def _team_wildcard_model(api_key: str, model_id: str = "team-wildcard-id") -> dict: return { "model_name": f"model_name_team-1_{model_id}", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6323516b126..966d2162a62 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -26789,10 +26789,24 @@ export interface components { aws_bedrock_project_id?: string | null; /** Aws Bedrock Runtime Endpoint */ aws_bedrock_runtime_endpoint?: string | null; + /** Aws External Id */ + aws_external_id?: string | null; + /** Aws Profile Name */ + aws_profile_name?: string | null; /** Aws Region Name */ aws_region_name?: string | null; + /** Aws Role Name */ + aws_role_name?: string | null; /** Aws Secret Access Key */ aws_secret_access_key?: string | null; + /** Aws Session Name */ + aws_session_name?: string | null; + /** Aws Session Token */ + aws_session_token?: string | null; + /** Aws Sts Endpoint */ + aws_sts_endpoint?: string | null; + /** Aws Web Identity Token */ + aws_web_identity_token?: string | null; /** Azure Ad Token */ azure_ad_token?: string | null; /** Budget Duration */ @@ -35467,10 +35481,24 @@ export interface components { aws_bedrock_project_id?: string | null; /** Aws Bedrock Runtime Endpoint */ aws_bedrock_runtime_endpoint?: string | null; + /** Aws External Id */ + aws_external_id?: string | null; + /** Aws Profile Name */ + aws_profile_name?: string | null; /** Aws Region Name */ aws_region_name?: string | null; + /** Aws Role Name */ + aws_role_name?: string | null; /** Aws Secret Access Key */ aws_secret_access_key?: string | null; + /** Aws Session Name */ + aws_session_name?: string | null; + /** Aws Session Token */ + aws_session_token?: string | null; + /** Aws Sts Endpoint */ + aws_sts_endpoint?: string | null; + /** Aws Web Identity Token */ + aws_web_identity_token?: string | null; /** Azure Ad Token */ azure_ad_token?: string | null; /** Budget Duration */ From b5eed5e5261cfe636fe8a8a272303abcd0a037fa Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:08:20 -0700 Subject: [PATCH 6/7] test(bedrock): cover the aws request param merge guard in the unit suite --- .../llms/bedrock/test_bedrock_common_utils.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py index 8cc6e4ff25d..83f3d73015d 100644 --- a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py @@ -473,3 +473,55 @@ def test_capability_lookups_fall_back_to_base_model_when_regional_entry_lacks_fi assert is_claude_4_5_on_bedrock(regional) is True assert bedrock_converse_supports_parallel_tool_use_config(regional) is True + + +def test_merge_bedrock_aws_request_params_strips_caller_identity_when_deployment_has_static_credentials(): + from litellm.llms.bedrock.common_utils import merge_bedrock_aws_request_params + + merged = merge_bedrock_aws_request_params( + litellm_params={ + "aws_access_key_id": "deployment-key", + "aws_secret_access_key": "deployment-secret", + "aws_region_name": "us-west-2", + "s3_bucket_name": "deployment-bucket", + }, + optional_params={ + "aws_access_key_id": "caller-key", + "aws_profile_name": "caller-profile", + "aws_role_name": "arn:aws:iam::123456789012:role/caller", + "aws_session_token": "caller-token", + "aws_web_identity_token": "caller-web-identity", + "timeout": 600, + }, + ) + + assert merged["aws_access_key_id"] == "deployment-key" + assert merged["aws_secret_access_key"] == "deployment-secret" + assert merged["aws_region_name"] == "us-west-2" + assert merged["s3_bucket_name"] == "deployment-bucket" + assert merged["timeout"] == 600 + for stripped in ( + "aws_profile_name", + "aws_role_name", + "aws_session_token", + "aws_web_identity_token", + ): + assert stripped not in merged + + +def test_merge_bedrock_aws_request_params_keeps_caller_credentials_without_static_deployment_credentials(): + from litellm.llms.bedrock.common_utils import merge_bedrock_aws_request_params + + merged = merge_bedrock_aws_request_params( + litellm_params={"aws_region_name": "us-west-2"}, + optional_params={ + "aws_access_key_id": "caller-key", + "aws_secret_access_key": "caller-secret", + "aws_session_token": "caller-token", + }, + ) + + assert merged["aws_access_key_id"] == "caller-key" + assert merged["aws_secret_access_key"] == "caller-secret" + assert merged["aws_session_token"] == "caller-token" + assert merged["aws_region_name"] == "us-west-2" From 1b488f7c2fb199d81086f44bbd570ea951118d01 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:05:49 -0700 Subject: [PATCH 7/7] fix(proxy): ban caller-supplied aws identity selectors in request bodies --- litellm/proxy/auth/auth_utils.py | 8 +++ .../proxy/auth/test_auth_utils.py | 69 +++++++++++++++++++ .../auth/test_banned_params_extra_body.py | 1 + 3 files changed, 78 insertions(+) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 3bfae4633c1..c9f9c00f120 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -262,6 +262,14 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "aws_sts_endpoint", "aws_web_identity_token", "aws_role_name", + # Remaining AWS identity selectors. ``get_credentials`` prefers a named + # profile over the deployment's static keys, so a caller-supplied + # ``aws_profile_name`` signs Bedrock and S3 requests as any profile + # present on the proxy host; the two AssumeRole knobs are banned with it + # so the whole identity-selection family lives behind the same opt-in. + "aws_profile_name", + "aws_session_name", + "aws_external_id", "vertex_credentials", # Azure managed-identity / federated-auth token. The Azure provider # transformer reads ``azure_ad_token`` (top-level or via diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 9ff2c38d98a..5becd05b8e8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -3131,3 +3131,72 @@ class TestHasUserSetupSso: monkeypatch.setenv("SAML_IDP_METADATA_XML", "") assert _has_user_setup_sso() is True + + +class TestIsRequestBodySafeBlocksAwsIdentitySelectors: + """A caller must not be able to redirect Bedrock signing to another identity + reachable from the proxy host. ``get_credentials`` prefers a named profile + and the AssumeRole knobs over the deployment's static keys, and the file / + batch endpoints fold the request body and the deployment credentials into a + single params dict, so these have to be rejected at the boundary (#36155). + """ + + @pytest.mark.parametrize( + "selector", + ["aws_profile_name", "aws_session_name", "aws_external_id"], + ) + def test_aws_identity_selector_in_batch_body_is_rejected(self, selector): + with pytest.raises(ValueError, match=selector): + is_request_body_safe( + request_body={ + "input_file_id": "file-abc123", + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "model": "bedrock-batch-model", + selector: "attacker-chosen", + }, + general_settings={}, + llm_router=None, + model="bedrock-batch-model", + ) + + @pytest.mark.parametrize( + "selector", + ["aws_profile_name", "aws_session_name", "aws_external_id"], + ) + def test_aws_identity_selector_under_extra_body_is_rejected(self, selector): + with pytest.raises(ValueError, match=selector): + is_request_body_safe( + request_body={ + "model": "bedrock-batch-model", + "extra_body": {selector: "attacker-chosen"}, + }, + general_settings={}, + llm_router=None, + model="bedrock-batch-model", + ) + + def test_aws_identity_selector_allowed_under_proxy_wide_opt_in(self): + assert ( + is_request_body_safe( + request_body={ + "model": "bedrock-batch-model", + "aws_profile_name": "admin-approved-profile", + }, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="bedrock-batch-model", + ) + is True + ) + + def test_upload_body_without_identity_selectors_is_accepted(self): + assert ( + is_request_body_safe( + request_body={"purpose": "batch", "model": "bedrock-batch-model"}, + general_settings={}, + llm_router=None, + model="bedrock-batch-model", + ) + is True + ) diff --git a/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py b/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py index 2ccee386281..e87b206a40a 100644 --- a/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py +++ b/tests/test_litellm/proxy/auth/test_banned_params_extra_body.py @@ -23,6 +23,7 @@ from litellm.proxy.auth.auth_utils import is_request_body_safe # noqa: E402 "aws_web_identity_token", "aws_sts_endpoint", "aws_role_name", + "aws_profile_name", "api_base", "base_url", "vertex_credentials",