diff --git a/src/openai/lib/_bedrock_auth.py b/src/openai/lib/_bedrock_auth.py index 2921858195..9edb9c03c4 100644 --- a/src/openai/lib/_bedrock_auth.py +++ b/src/openai/lib/_bedrock_auth.py @@ -23,6 +23,28 @@ def get_credentials(self) -> object | None: ... "x-amz-date", "x-amz-security-token", ) +# Volatile transport headers that intermediaries (proxies, load balancers, and +# other nodes) may add or rewrite in transit. AWS SigV4 guidance is to exclude +# these from the signature so such rewrites do not invalidate it. This mirrors +# botocore's own SIGNED_HEADERS_BLACKLIST so signing behavior is identical across +# the supported botocore>=1.40.0 range, including releases that predate the +# equivalent upstream fix (boto/botocore#3643). +# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html +_UNSIGNED_HEADERS = frozenset( + { + "connection", + "expect", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "user-agent", + "x-amzn-trace-id", + } +) def _load_botocore() -> tuple[Any, Any, Any, Any]: @@ -142,7 +164,9 @@ def sign(self, *, method: str, url: str, headers: Mapping[str, str], body: bytes credentials = get_frozen_credentials() signed_headers = { - name: value for name, value in headers.items() if name.lower() not in _AWS_SIGNING_HEADERS + name: value + for name, value in headers.items() + if name.lower() not in _AWS_SIGNING_HEADERS and name.lower() not in _UNSIGNED_HEADERS } signed_headers["X-Amz-Content-SHA256"] = hashlib.sha256(body or b"").hexdigest() aws_request = self._aws_request_cls( @@ -160,7 +184,15 @@ def sign(self, *, method: str, url: str, headers: Mapping[str, str], body: bytes "or runtime identity configuration and try again." ) from exc - return dict(aws_request.headers.items()) + result = dict(aws_request.headers.items()) + # Preserve the caller's volatile transport headers (e.g. an explicit + # `Connection: close`) on the outgoing request. They were excluded from + # the signature above, not from the request itself, matching how botocore + # leaves blacklisted headers on the request while keeping them unsigned. + for name, value in headers.items(): + if name.lower() in _UNSIGNED_HEADERS and name not in result: + result[name] = value + return result def resolve_aws_region_with_source( diff --git a/tests/lib/test_bedrock_auth_conformance.py b/tests/lib/test_bedrock_auth_conformance.py index 7478b08cbd..b3c12857de 100644 --- a/tests/lib/test_bedrock_auth_conformance.py +++ b/tests/lib/test_bedrock_auth_conformance.py @@ -113,6 +113,59 @@ def test_shared_sigv4_fixture_matches_node(monkeypatch: pytest.MonkeyPatch) -> N assert signed_headers["x-amz-date"] == fixture["expected"]["date"] +# Volatile transport headers that intermediaries may add or rewrite in transit. +# These must be excluded from the SigV4 signature (but left on the request) so a +# rewrite does not cause a signature mismatch. Mirrors botocore's +# SIGNED_HEADERS_BLACKLIST and the AWS SigV4 guidance: +# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html +_UNSIGNED_TRANSPORT_HEADERS = [ + "connection", + "expect", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "user-agent", + "x-amzn-trace-id", +] + + +@pytest.mark.parametrize("header_name", _UNSIGNED_TRANSPORT_HEADERS) +def test_sign_excludes_volatile_transport_header(header_name: str) -> None: + auth = BedrockAwsAuth( + BedrockAwsAuthConfig( + region="us-east-1", + source="static", + access_key_id="AKIDEXAMPLE", + secret_access_key="wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + session_token="session-token", + ) + ) + # Send the header with a mixed-case name to exercise case-insensitive matching. + mixed_case_name = header_name.title() + result = auth.sign( + method="POST", + url="https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses", + headers={ + "content-type": "application/json", + "host": "bedrock-mantle.us-east-1.api.aws", + mixed_case_name: "example-value", + }, + body=b"{}", + ) + signed_headers = _lower_headers(result) + signed_header_names = signed_headers["authorization"].split("SignedHeaders=", 1)[1].split(",", 1)[0].split(";") + + # Excluded from the signature so an intermediary rewriting it cannot break signing. + assert header_name not in signed_header_names + # But preserved on the outgoing request (unsigned), matching botocore's behavior. + assert header_name in signed_headers + assert signed_headers[header_name] == "example-value" + + @pytest.mark.parametrize("case", _cases("auth_selection"), ids=lambda case: case["id"]) def test_auth_selection_fixture(case: dict[str, Any], monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)