From d30f782f92c31f4f3e73d76a4693283bee7df26c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 17:59:05 -0500 Subject: [PATCH] Fix digest authentication for URLs with reserved characters The digest middleware computed the signature using yarl's path_qs, which returns the decoded form of the path and query string; aiohttp transmits the encoded form (raw_path_qs) on the wire, so servers signed a different request-target than the client and rejected the response with 401. Switch the digest A2 and the Authorization uri field to raw_path_qs so the signed value matches what is sent on the wire. --- CHANGES/12436.bugfix.rst | 1 + aiohttp/client_middleware_digest_auth.py | 6 ++- tests/test_client_middleware_digest_auth.py | 41 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 CHANGES/12436.bugfix.rst diff --git a/CHANGES/12436.bugfix.rst b/CHANGES/12436.bugfix.rst new file mode 100644 index 00000000000..d6f7e160697 --- /dev/null +++ b/CHANGES/12436.bugfix.rst @@ -0,0 +1 @@ +Fixed digest authentication failing for requests whose path or query string contains percent-encoded reserved characters; the digest signature now uses the encoded request-target that is sent on the wire instead of the decoded form -- by :user:`bdraco`. diff --git a/aiohttp/client_middleware_digest_auth.py b/aiohttp/client_middleware_digest_auth.py index 64257a65c18..8151dea5154 100644 --- a/aiohttp/client_middleware_digest_auth.py +++ b/aiohttp/client_middleware_digest_auth.py @@ -246,7 +246,11 @@ async def _encode(self, method: str, url: URL, body: Payload | Literal[b""]) -> # Convert string values to bytes once nonce_bytes = nonce.encode("utf-8") realm_bytes = realm.encode("utf-8") - path = URL(url).path_qs + # Use the encoded request-target (raw_path_qs) since that is what is + # transmitted on the wire and what the server signs against. Using the + # decoded form would cause digest verification to fail when the path + # or query string contains percent-encoded reserved characters. + path = URL(url).raw_path_qs # Process QoP qop = "" diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index c490fb70d78..0d2d6ad3325 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -189,6 +189,47 @@ async def test_encode_digest_with_md5( assert "algorithm=MD5" in header +@pytest.mark.parametrize( + ("url", "expected_uri"), + [ + ( + URL("http://example.com/axis-cgi/io/port.cgi?action=9:\\"), + "/axis-cgi/io/port.cgi?action=9:%5C", + ), + ( + URL("http://example.com/path with space/file"), + "/path%20with%20space/file", + ), + ( + URL("http://example.com/p?q=a&b=1+2"), + "/p?q=a&b=1+2", + ), + ( + URL.build( + scheme="http", + host="example.com", + path="/p", + query={"x": "[]"}, + ), + "/p?x=%5B%5D", + ), + ], + ids=["backslash-and-colon", "space-in-path", "ampersand-and-plus", "brackets"], +) +async def test_encode_uri_uses_wire_encoded_request_target( + auth_mw_with_challenge: DigestAuthMiddleware, + url: URL, + expected_uri: str, +) -> None: + """The digest uri/A2 must use the encoded request-target sent on the wire. + + Servers compute the digest signature against the encoded request-target + they actually receive, so the client must sign the same encoded form. + """ + header = await auth_mw_with_challenge._encode("GET", url, b"") + assert f'uri="{expected_uri}"' in header + + @pytest.mark.parametrize( "algorithm", ["MD5-SESS", "SHA-SESS", "SHA-256-SESS", "SHA-512-SESS"] )