From f37f83679bf53144f63761272fb5288981f1cbc9 Mon Sep 17 00:00:00 2001 From: Yuxuan Chen Date: Mon, 30 Mar 2026 17:03:17 -0400 Subject: [PATCH 1/3] Fix SigV4 signature for URIs with literal query parameters --- ...s-sdk-signers-bugfix-23a4eca5165f48efb1524b95f8498299.json | 4 ++++ packages/aws-sdk-signers/src/aws_sdk_signers/signers.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 packages/aws-sdk-signers/.changes/next-release/aws-sdk-signers-bugfix-23a4eca5165f48efb1524b95f8498299.json 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 From 40d2dfc0dfe63adb122a0e533c2456d518ec164f Mon Sep 17 00:00:00 2001 From: Yuxuan Chen Date: Mon, 30 Mar 2026 18:25:53 -0400 Subject: [PATCH 2/3] Add unit tests for _format_canonical_query in SigV4Signer --- .../tests/unit/test_signers.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/aws-sdk-signers/tests/unit/test_signers.py b/packages/aws-sdk-signers/tests/unit/test_signers.py index cafef288f..e11b1dad9 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( + 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( + 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( + 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( + query="sync" + ) + assert canonical_query == "sync=" From 92be2d2f23c639475bb21b5a6aace7208f048049 Mon Sep 17 00:00:00 2001 From: Yuxuan Chen Date: Tue, 31 Mar 2026 09:54:38 -0400 Subject: [PATCH 3/3] Fix check-py issues --- packages/aws-sdk-signers/tests/unit/test_signers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/aws-sdk-signers/tests/unit/test_signers.py b/packages/aws-sdk-signers/tests/unit/test_signers.py index e11b1dad9..76edd74ae 100644 --- a/packages/aws-sdk-signers/tests/unit/test_signers.py +++ b/packages/aws-sdk-signers/tests/unit/test_signers.py @@ -126,13 +126,13 @@ def test_sign_with_expired_identity( ) def test_format_canonical_query_keeps_blank_values(self) -> None: - canonical_query = self.SIGV4_SYNC_SIGNER._format_canonical_query( + 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( + canonical_query = self.SIGV4_SYNC_SIGNER._format_canonical_query( # pyright: ignore[reportPrivateUsage] query="sync" ) assert canonical_query == "sync=" @@ -245,13 +245,13 @@ async def test_sign_event_stream( 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( + 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( + canonical_query = await self.SIGV4_ASYNC_SIGNER._format_canonical_query( # pyright: ignore[reportPrivateUsage] query="sync" ) assert canonical_query == "sync="