diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index 4cbfd599d8f..399f11a94cf 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -759,7 +759,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): import hashlib import requests - from botocore.auth import SigV4Auth + from botocore.auth import S3SigV4Auth from botocore.awsrequest import AWSRequest except ImportError: raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") @@ -804,7 +804,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): # Get region name for non-LLM API calls (same as s3_v2.py) signing_region: Final = self.get_aws_region_name_for_non_llm_api_calls(aws_region_name=aws_region_name) - SigV4Auth(credentials, "s3", signing_region).add_auth(aws_request) + S3SigV4Auth(credentials, "s3", signing_region).add_auth(aws_request) # Return signed headers and body signed_body = aws_request.body @@ -1015,7 +1015,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): try: import hashlib - from botocore.auth import SigV4Auth + from botocore.auth import S3SigV4Auth from botocore.awsrequest import AWSRequest except ImportError: raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") @@ -1038,7 +1038,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): url=api_base, headers={"x-amz-content-sha256": empty_body_hash}, ) - auth: Final = SigV4Auth(credentials, "s3", aws_region_name) # any-ok: botocore untyped + auth: Final = S3SigV4Auth(credentials, "s3", aws_region_name) # any-ok: botocore untyped auth.add_auth(aws_request) # any-ok: botocore request mutation is untyped return dict(aws_request.headers) # any-ok: botocore headers are untyped diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index c548fe53e15..2ec0c00db26 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -4,10 +4,14 @@ Test bedrock files transformation functionality import json import os +from collections.abc import Mapping from unittest.mock import MagicMock from urllib.parse import unquote, urlparse import pytest +from botocore.auth import S3SigV4Auth, SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import Credentials from litellm.llms.bedrock.files.transformation import BedrockJsonlFilesTransformation @@ -1213,9 +1217,16 @@ class TestBedrockFileContentTransformation: assert params == {} signed_headers = litellm_params[S3_SIGNED_GET_HEADERS_PARAM] - assert ( - signed_headers["x-amz-content-sha256"] == hashlib.sha256(b"").hexdigest() - ), "GET has no payload, so the content hash must be the empty-body hash" + content_hashes = { + value + for name, value in signed_headers.items() + if name.lower() == "x-amz-content-sha256" + } + assert content_hashes == {hashlib.sha256(b"").hexdigest()}, ( + "GET has no payload, so the content hash must be the empty-body hash." + " The header name is matched case-insensitively because botocore picks" + " its own casing and HTTP header names are case-insensitive" + ) authorization = signed_headers["Authorization"] assert authorization.startswith("AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/") assert "/us-west-2/s3/aws4_request" in authorization @@ -1487,3 +1498,129 @@ class TestBedrockFileContentTransformation: .startswith("AWS4-HMAC-SHA256") ) assert response.content == b'{"recordId": "x"}' + + +class TestBedrockFilesS3SignatureEncoding: + """ + S3 rebuilds the canonical request from the wire path with single percent-encoding, + which botocore models as S3SigV4Auth. Plain SigV4Auth quotes the already encoded + path a second time, so an object key holding any character that percent-encodes + (a configured bucket prefix with a space) is signed over %2520 while the request + carries %20, and S3 answers 403 SignatureDoesNotMatch. + """ + + ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE" + SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + BUCKET_WITH_SPACED_PREFIX = "my-bucket/LLM AI Projects" + REGION = "us-west-2" + + def _credential_params(self) -> dict[str, str]: + return { + "aws_access_key_id": self.ACCESS_KEY, + "aws_secret_access_key": self.SECRET_KEY, + "aws_region_name": self.REGION, + } + + def _signature_under( + self, + signer_cls: type[SigV4Auth], + method: str, + url: str, + body: bytes | None, + headers: Mapping[str, str], + ) -> str: + sent = {name.lower(): value for name, value in headers.items()} + signed_names = ( + sent["authorization"].split("SignedHeaders=")[1].split(",")[0].split(";") + ) + request = AWSRequest( + method=method, + url=url, + data=body, + headers={name: sent[name] for name in signed_names if name in sent}, + ) + request.context["timestamp"] = sent["x-amz-date"] + signer = signer_cls( + Credentials(self.ACCESS_KEY, self.SECRET_KEY), "s3", self.REGION + ) + return signer.signature( + signer.string_to_sign(request, signer.canonical_request(request)), request + ) + + def _assert_signed_the_way_s3_reads_it( + self, + method: str, + url: str, + body: bytes | None, + headers: Mapping[str, str], + ) -> None: + assert "%20" in url, "the object key must reach the wire percent-encoded" + sent_signature = headers["Authorization"].split("Signature=")[1].strip() + assert sent_signature == self._signature_under( + S3SigV4Auth, method, url, body, headers + ) + assert sent_signature != self._signature_under( + SigV4Auth, method, url, body, headers + ) + + def test_create_file_signs_spaced_object_key_the_way_s3_does(self) -> None: + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + content = json.dumps( + { + "custom_id": "1", + "body": { + "model": "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + "messages": [{"role": "user", "content": "hello"}], + }, + } + ) + signed = BedrockFilesConfig().transform_create_file_request( + model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0", + create_file_data={ + "file": ("batch.jsonl", content.encode("utf-8"), "application/jsonl"), + "purpose": "batch", + }, + optional_params=self._credential_params(), + litellm_params={ + "s3_bucket_name": self.BUCKET_WITH_SPACED_PREFIX, + "s3_region_name": self.REGION, + }, + ) + + self._assert_signed_the_way_s3_reads_it( + method="PUT", + url=signed["url"], + body=signed["data"].encode("utf-8"), + headers=signed["headers"], + ) + + def test_file_content_signs_spaced_object_key_the_way_s3_does( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from litellm.llms.bedrock.files.transformation import ( + S3_SIGNED_GET_HEADERS_PARAM, + BedrockFilesConfig, + ) + + monkeypatch.setenv("AWS_S3_BUCKET_NAME", self.BUCKET_WITH_SPACED_PREFIX) + litellm_params = { + "s3_bucket_name": self.BUCKET_WITH_SPACED_PREFIX, + "s3_region_name": self.REGION, + **self._credential_params(), + } + + url, _ = BedrockFilesConfig().transform_file_content_request( + file_content_request={ + "file_id": "s3://my-bucket/LLM AI Projects/litellm-bedrock-files-model-abc.jsonl" + }, + optional_params={}, + litellm_params=litellm_params, + ) + + self._assert_signed_the_way_s3_reads_it( + method="GET", + url=url, + body=None, + headers=litellm_params[S3_SIGNED_GET_HEADERS_PARAM], + )