fix(bedrock/claude-platform): normalize content-type header case before SigV4 signing

When the caller passes headers with a lowercase "content-type" key (as
get_anthropic_headers() does), and _sign_request prepends an uppercase
"Content-Type" key, both survive as separate entries in a plain Python
dict.  botocore's AWSRequest uses a case-insensitive HeadersDict, so it
sees two values for the same header and joins them into
"application/json, application/json" in the SigV4 canonical string.

The actual HTTP request only sends one value, so the signature never
matches → 401 authentication_error from aws-external-anthropic.

Fix: normalise all header keys to lowercase before signing with
setdefault() to add content-type only if not already present.

Adds a regression test that captures the AWSRequest headers and asserts
exactly one content-type key reaches the signer.

Fixes: duplicate Content-Type in aws-external-anthropic SigV4 canonical string
This commit is contained in:
Connor Graham 2026-05-19 09:58:20 -04:00
parent cff3e0b75e
commit 9c4c27f6d5
2 changed files with 80 additions and 5 deletions

View file

@ -1499,10 +1499,14 @@ class BaseAWSLLM:
)
sigv4 = SigV4Auth(credentials, service_name, aws_region_name)
if headers is not None:
headers = {"Content-Type": "application/json", **headers}
else:
headers = {"Content-Type": "application/json"}
# Normalize header keys to lowercase before merging to prevent duplicate
# Content-Type/content-type entries in the SigV4 canonical string. If
# both cases survive as separate dict keys, botocore joins their values
# ("application/json, application/json") and the resulting signature
# doesn't match the single-value header sent over the wire (401).
normalized: dict = {k.lower(): v for k, v in (headers or {}).items()}
normalized.setdefault("content-type", "application/json")
headers = normalized
aws_signature_headers = self._filter_headers_for_aws_signature(headers)
request = AWSRequest(

View file

@ -1,8 +1,9 @@
import json
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import httpx
import pytest
from botocore.credentials import Credentials
def _anthropic_response(url: str) -> httpx.Response:
@ -310,3 +311,73 @@ async def test_anthropic_messages_routes_bedrock_claude_platform_to_messages_api
assert requests[0]["body"]["messages"] == [{"role": "user", "content": "hello"}]
assert requests[0]["body"]["max_tokens"] == 10
assert requests[0]["body"]["model"] == "claude-sonnet-4-6"
def test_sigv4_no_duplicate_content_type_in_canonical_string():
"""
Regression test: when the caller already sets content-type (lowercase) and
_sign_request also sets Content-Type (uppercase), both keys survive in a
plain Python dict and botocore joins their values into
"application/json, application/json" in the SigV4 canonical string. The
actual request only sends one value, so the signature never matches → 401.
After the fix, header keys are normalised to lowercase before signing so
content-type appears exactly once regardless of what the caller sent.
"""
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
llm = BaseAWSLLM()
mock_credentials = Credentials("test_key", "test_secret", "test_token")
mock_sigv4 = MagicMock()
captured_aws_request_headers: list[dict] = []
def fake_aws_request(method, url, data, headers):
captured_aws_request_headers.append(dict(headers))
req = MagicMock()
req.headers = {
"Authorization": "AWS4-HMAC-SHA256 Credential=test",
"content-type": "application/json",
"x-amz-date": "20260101T000000Z",
}
req.body = data.encode() if isinstance(data, str) else data
return req
with (
patch("botocore.auth.SigV4Auth", return_value=mock_sigv4),
patch(
"botocore.awsrequest.AWSRequest",
side_effect=fake_aws_request,
),
patch.object(llm, "get_credentials", return_value=mock_credentials),
patch.object(llm, "_get_aws_region_name", return_value="us-east-1"),
):
llm._sign_request(
service_name="aws-external-anthropic",
# Simulate headers that already carry lowercase content-type,
# as set by get_anthropic_headers() before sign_request is called.
headers={
"content-type": "application/json",
"anthropic-version": "2023-06-01",
},
optional_params={"aws_region_name": "us-east-1"},
request_data={
"model": "claude-sonnet-4-6",
"messages": [],
"max_tokens": 10,
},
api_base="https://aws-external-anthropic.us-east-1.api.aws/v1/messages",
)
assert len(captured_aws_request_headers) == 1
signed_headers = captured_aws_request_headers[0]
# content-type must appear exactly once — no "Content-Type" duplicate key
ct_keys = [k for k in signed_headers if k.lower() == "content-type"]
assert len(ct_keys) == 1, (
f"Expected exactly one content-type key, got {ct_keys}. "
"Duplicate keys cause SigV4 canonical string to contain "
"'application/json, application/json' and a 401 from AWS."
)
assert signed_headers[ct_keys[0]] == "application/json"