diff --git a/packages/aws-sdk-signers/.changes/next-release/aws-sdk-signers-bugfix-23a4eca5165f48efb1524b95f8498299.json b/packages/aws-sdk-signers/.changes/next-release/aws-sdk-signers-bugfix-23a4eca5165f48efb1524b95f8498299.json new file mode 100644 index 000000000..000dfbad6 --- /dev/null +++ b/packages/aws-sdk-signers/.changes/next-release/aws-sdk-signers-bugfix-23a4eca5165f48efb1524b95f8498299.json @@ -0,0 +1,4 @@ +{ + "type": "bugfix", + "description": "Fixed SigV4 signature computation for URIs with literal query parameters (e.g., ?sync). parse_qsl was silently dropping query keys without values, causing InvalidSignatureException." +} \ No newline at end of file diff --git a/packages/aws-sdk-signers/src/aws_sdk_signers/signers.py b/packages/aws-sdk-signers/src/aws_sdk_signers/signers.py index f74179be5..930fad1ef 100644 --- a/packages/aws-sdk-signers/src/aws_sdk_signers/signers.py +++ b/packages/aws-sdk-signers/src/aws_sdk_signers/signers.py @@ -315,7 +315,7 @@ def _format_canonical_query(self, *, query: str | None) -> str: if query is None: return "" - query_params = parse_qsl(qs=query) + query_params = parse_qsl(qs=query, keep_blank_values=True) query_parts = ( (quote(string=key, safe=""), quote(string=value, safe="")) for key, value in query_params @@ -695,7 +695,7 @@ async def _format_canonical_query(self, *, query: str | None) -> str: if query is None: return "" - query_params = parse_qsl(qs=query) + query_params = parse_qsl(qs=query, keep_blank_values=True) query_parts = ( (quote(string=key, safe=""), quote(string=value, safe="")) for key, value in query_params diff --git a/packages/aws-sdk-signers/tests/unit/test_signers.py b/packages/aws-sdk-signers/tests/unit/test_signers.py index cafef288f..76edd74ae 100644 --- a/packages/aws-sdk-signers/tests/unit/test_signers.py +++ b/packages/aws-sdk-signers/tests/unit/test_signers.py @@ -125,6 +125,18 @@ def test_sign_with_expired_identity( identity=identity, ) + def test_format_canonical_query_keeps_blank_values(self) -> None: + canonical_query = self.SIGV4_SYNC_SIGNER._format_canonical_query( # pyright: ignore[reportPrivateUsage] + query="foo=bar&baz=" + ) + assert canonical_query == "baz=&foo=bar" + + def test_format_canonical_query_with_literal_query_param(self) -> None: + canonical_query = self.SIGV4_SYNC_SIGNER._format_canonical_query( # pyright: ignore[reportPrivateUsage] + query="sync" + ) + assert canonical_query == "sync=" + class UnreadableAsyncStream: def __aiter__(self) -> typing.Self: @@ -231,3 +243,15 @@ async def test_sign_event_stream( assert "X-Amz-Content-SHA256" in signed.fields payload_hash = signed.fields["X-Amz-Content-SHA256"].as_string() assert payload_hash == "STREAMING-AWS4-HMAC-SHA256-EVENTS" + + async def test_format_canonical_query_keeps_blank_values(self) -> None: + canonical_query = await self.SIGV4_ASYNC_SIGNER._format_canonical_query( # pyright: ignore[reportPrivateUsage] + query="foo=bar&baz=" + ) + assert canonical_query == "baz=&foo=bar" + + async def test_format_canonical_query_with_literal_query_param(self) -> None: + canonical_query = await self.SIGV4_ASYNC_SIGNER._format_canonical_query( # pyright: ignore[reportPrivateUsage] + query="sync" + ) + assert canonical_query == "sync="