mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
de00655363
commit
267adf709a
2 changed files with 144 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue