From 5f95083cb3fe489b24df20b1d49b36af43fed839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 11:57:55 -1000 Subject: [PATCH 1/5] Fix algorithm case preservation in DigestAuthMiddleware --- aiohttp/client_middleware_digest_auth.py | 6 +- tests/test_client_middleware_digest_auth.py | 77 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/aiohttp/client_middleware_digest_auth.py b/aiohttp/client_middleware_digest_auth.py index 35f462f180b..c1ed7ca0fdd 100644 --- a/aiohttp/client_middleware_digest_auth.py +++ b/aiohttp/client_middleware_digest_auth.py @@ -245,7 +245,9 @@ async def _encode( ) qop_raw = challenge.get("qop", "") - algorithm = challenge.get("algorithm", "MD5").upper() + # Preserve original algorithm case for response while using uppercase for processing + algorithm_original = challenge.get("algorithm", "MD5") + algorithm = algorithm_original.upper() opaque = challenge.get("opaque", "") # Convert string values to bytes once @@ -342,7 +344,7 @@ def KD(s: bytes, d: bytes) -> bytes: "nonce": escape_quotes(nonce), "uri": path, "response": response_digest.decode(), - "algorithm": algorithm, + "algorithm": algorithm_original, } # Optional fields diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 16959aecdf4..ee5a3ae4d6b 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -1,6 +1,7 @@ """Test digest authentication middleware for aiohttp client.""" import io +import re from hashlib import md5, sha1 from typing import Generator, Literal, Union from unittest import mock @@ -211,6 +212,31 @@ async def test_encode_unsupported_algorithm( await digest_auth_mw._encode("GET", URL("http://example.com/resource"), b"") +@pytest.mark.parametrize( + "algorithm", ["MD5", "md5", "MD5-sess", "MD5-SESS", "sha-256", "SHA-256"] +) +async def test_encode_algorithm_case_preservation( + digest_auth_mw: DigestAuthMiddleware, + qop_challenge: DigestAuthChallenge, + algorithm: str, +) -> None: + """Test that algorithm case is preserved in the response header.""" + # Create a challenge with the specific algorithm case + challenge = qop_challenge.copy() + challenge["algorithm"] = algorithm + digest_auth_mw._challenge = challenge + + header = await digest_auth_mw._encode( + "GET", URL("http://example.com/resource"), b"" + ) + + # The algorithm in the response should match the exact case from the challenge + assert f"algorithm={algorithm}" in header + # Also verify it's not the uppercase version if the original wasn't uppercase + if algorithm != algorithm.upper(): + assert f"algorithm={algorithm.upper()}" not in header + + async def test_invalid_qop_rejected( digest_auth_mw: DigestAuthMiddleware, basic_challenge: DigestAuthChallenge ) -> None: @@ -1231,3 +1257,54 @@ def test_in_protection_space_multiple_spaces( digest_auth_mw._in_protection_space(URL("http://example.com/secure")) is False ) assert digest_auth_mw._in_protection_space(URL("http://example.com/other")) is False + + +async def test_case_sensitive_algorithm_server( + aiohttp_server: AiohttpServer, +) -> None: + """Test authentication with a server that requires exact algorithm case matching. + + This simulates servers like Prusa printers that expect the algorithm + to be returned with the exact same case as sent in the challenge. + """ + digest_auth_mw = DigestAuthMiddleware("testuser", "testpass") + request_count = 0 + auth_algorithms: list[str] = [] + + async def handler(request: Request) -> Response: + nonlocal request_count + request_count += 1 + + if not (auth_header := request.headers.get(hdrs.AUTHORIZATION)): + # Send challenge with lowercase-sess algorithm (like Prusa) + challenge = 'Digest realm="Administrator", nonce="test123", qop="auth", algorithm="MD5-sess", opaque="xyz123"' + return Response( + status=401, + headers={"WWW-Authenticate": challenge}, + text="Unauthorized", + ) + + # Extract algorithm from auth response + if algo_match := re.search(r"algorithm=([^,\s]+)", auth_header): + auth_algorithms.append(algo_match.group(1)) + + # Case-sensitive server: only accept exact case match + assert "algorithm=MD5-sess" in auth_header + return Response(text="Success") + + app = Application() + app.router.add_get("/api/test", handler) + server = await aiohttp_server(app) + + async with ( + ClientSession(middlewares=(digest_auth_mw,)) as session, + session.get(server.make_url("/api/test")) as resp, + ): + assert resp.status == 200 + text = await resp.text() + assert text == "Success" + + # Verify the middleware preserved the exact algorithm case + assert request_count == 2 # Initial 401 + successful retry + assert len(auth_algorithms) == 1 + assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS" From b4413d77f78196fbe604ddc878b2dc756eb1b173 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:01:05 -1000 Subject: [PATCH 2/5] changelog --- CHANGES/11352.bugfix.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 CHANGES/11352.bugfix.rst diff --git a/CHANGES/11352.bugfix.rst b/CHANGES/11352.bugfix.rst new file mode 100644 index 00000000000..165fb15b78c --- /dev/null +++ b/CHANGES/11352.bugfix.rst @@ -0,0 +1,2 @@ +Fixed ``DigestAuthMiddleware`` to preserve the algorithm case from the server's challenge in the authorization response. This improves compatibility with servers that perform case-sensitive algorithm matching (e.g., servers expecting ``algorithm=MD5-sess`` instead of ``algorithm=MD5-SESS``) +-- by :user:`bdraco`. From 3a66edfc2770dff6f5c9a4e8f004572f4a96b78b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:02:06 -1000 Subject: [PATCH 3/5] split test --- tests/test_client_middleware_digest_auth.py | 33 ++++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index ee5a3ae4d6b..80563da1b3d 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -212,15 +212,33 @@ async def test_encode_unsupported_algorithm( await digest_auth_mw._encode("GET", URL("http://example.com/resource"), b"") -@pytest.mark.parametrize( - "algorithm", ["MD5", "md5", "MD5-sess", "MD5-SESS", "sha-256", "SHA-256"] -) -async def test_encode_algorithm_case_preservation( +@pytest.mark.parametrize("algorithm", ["MD5", "MD5-SESS", "SHA-256"]) +async def test_encode_algorithm_case_preservation_uppercase( + digest_auth_mw: DigestAuthMiddleware, + qop_challenge: DigestAuthChallenge, + algorithm: str, +) -> None: + """Test that uppercase algorithm case is preserved in the response header.""" + # Create a challenge with the specific algorithm case + challenge = qop_challenge.copy() + challenge["algorithm"] = algorithm + digest_auth_mw._challenge = challenge + + header = await digest_auth_mw._encode( + "GET", URL("http://example.com/resource"), b"" + ) + + # The algorithm in the response should match the exact case from the challenge + assert f"algorithm={algorithm}" in header + + +@pytest.mark.parametrize("algorithm", ["md5", "MD5-sess", "sha-256"]) +async def test_encode_algorithm_case_preservation_lowercase( digest_auth_mw: DigestAuthMiddleware, qop_challenge: DigestAuthChallenge, algorithm: str, ) -> None: - """Test that algorithm case is preserved in the response header.""" + """Test that lowercase/mixed-case algorithm is preserved in the response header.""" # Create a challenge with the specific algorithm case challenge = qop_challenge.copy() challenge["algorithm"] = algorithm @@ -232,9 +250,8 @@ async def test_encode_algorithm_case_preservation( # The algorithm in the response should match the exact case from the challenge assert f"algorithm={algorithm}" in header - # Also verify it's not the uppercase version if the original wasn't uppercase - if algorithm != algorithm.upper(): - assert f"algorithm={algorithm.upper()}" not in header + # Also verify it's not the uppercase version + assert f"algorithm={algorithm.upper()}" not in header async def test_invalid_qop_rejected( From ef9cbb1aa2dd40b220da3f96479cee9877a77f94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:08:50 -1000 Subject: [PATCH 4/5] cleanup --- CHANGES/11352.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES/11352.bugfix.rst b/CHANGES/11352.bugfix.rst index 165fb15b78c..3ccc8a58d2f 100644 --- a/CHANGES/11352.bugfix.rst +++ b/CHANGES/11352.bugfix.rst @@ -1,2 +1,2 @@ -Fixed ``DigestAuthMiddleware`` to preserve the algorithm case from the server's challenge in the authorization response. This improves compatibility with servers that perform case-sensitive algorithm matching (e.g., servers expecting ``algorithm=MD5-sess`` instead of ``algorithm=MD5-SESS``) +Fixed :class:`~aiohttp.DigestAuthMiddleware` to preserve the algorithm case from the server's challenge in the authorization response. This improves compatibility with servers that perform case-sensitive algorithm matching (e.g., servers expecting ``algorithm=MD5-sess`` instead of ``algorithm=MD5-SESS``) -- by :user:`bdraco`. From d0bc3b104d7852b5d0f6a81afac318841756cd0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Jul 2025 12:18:22 -1000 Subject: [PATCH 5/5] preen --- tests/test_client_middleware_digest_auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_client_middleware_digest_auth.py b/tests/test_client_middleware_digest_auth.py index 80563da1b3d..2059bfea337 100644 --- a/tests/test_client_middleware_digest_auth.py +++ b/tests/test_client_middleware_digest_auth.py @@ -1302,8 +1302,9 @@ async def handler(request: Request) -> Response: ) # Extract algorithm from auth response - if algo_match := re.search(r"algorithm=([^,\s]+)", auth_header): - auth_algorithms.append(algo_match.group(1)) + algo_match = re.search(r"algorithm=([^,\s]+)", auth_header) + assert algo_match is not None + auth_algorithms.append(algo_match.group(1)) # Case-sensitive server: only accept exact case match assert "algorithm=MD5-sess" in auth_header