fix(bedrock): sign Bedrock managed-file S3 requests with S3SigV4Auth (#35983)

S3 rebuilds the canonical request from the wire path with single
percent-encoding, which botocore models as S3SigV4Auth. Generic SigV4Auth
quotes the already encoded path a second time, so an object key holding any
character that percent-encodes was signed over %2520 while the request
carried %20, and S3 answered 403 SignatureDoesNotMatch.

The S3 logger was corrected in #35726; the Bedrock managed-files upload and
retrieval paths copied that same pre-fix pattern and were left behind, so a
configured bucket prefix with a space 403s every file upload and every file
content read.
This commit is contained in:
Yassin Kortam 2026-08-05 13:43:30 -07:00 • committed by GitHub
parent de00655363
commit 267adf709a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 144 additions and 7 deletions

View file

@ -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

View file

@ -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],
)