Skip to content

Commit 0d5a54c

Browse files
[PR #11352/6deeceaf backport][3.12] Fix algorithm case preservation in DigestAuthMiddleware (#11353)
Co-authored-by: J. Nick Koston <[email protected]> Fixes home-assistant/core#149196
1 parent baaaa1b commit 0d5a54c

File tree

3 files changed

+101
-2
lines changed

3 files changed

+101
-2
lines changed

CHANGES/11352.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
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``)
2+
-- by :user:`bdraco`.

aiohttp/client_middleware_digest_auth.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ async def _encode(
245245
)
246246

247247
qop_raw = challenge.get("qop", "")
248-
algorithm = challenge.get("algorithm", "MD5").upper()
248+
# Preserve original algorithm case for response while using uppercase for processing
249+
algorithm_original = challenge.get("algorithm", "MD5")
250+
algorithm = algorithm_original.upper()
249251
opaque = challenge.get("opaque", "")
250252

251253
# Convert string values to bytes once
@@ -342,7 +344,7 @@ def KD(s: bytes, d: bytes) -> bytes:
342344
"nonce": escape_quotes(nonce),
343345
"uri": path,
344346
"response": response_digest.decode(),
345-
"algorithm": algorithm,
347+
"algorithm": algorithm_original,
346348
}
347349

348350
# Optional fields

tests/test_client_middleware_digest_auth.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test digest authentication middleware for aiohttp client."""
22

33
import io
4+
import re
45
from hashlib import md5, sha1
56
from typing import Generator, Literal, Union
67
from unittest import mock
@@ -211,6 +212,48 @@ async def test_encode_unsupported_algorithm(
211212
await digest_auth_mw._encode("GET", URL("http://example.com/resource"), b"")
212213

213214

215+
@pytest.mark.parametrize("algorithm", ["MD5", "MD5-SESS", "SHA-256"])
216+
async def test_encode_algorithm_case_preservation_uppercase(
217+
digest_auth_mw: DigestAuthMiddleware,
218+
qop_challenge: DigestAuthChallenge,
219+
algorithm: str,
220+
) -> None:
221+
"""Test that uppercase algorithm case is preserved in the response header."""
222+
# Create a challenge with the specific algorithm case
223+
challenge = qop_challenge.copy()
224+
challenge["algorithm"] = algorithm
225+
digest_auth_mw._challenge = challenge
226+
227+
header = await digest_auth_mw._encode(
228+
"GET", URL("http://example.com/resource"), b""
229+
)
230+
231+
# The algorithm in the response should match the exact case from the challenge
232+
assert f"algorithm={algorithm}" in header
233+
234+
235+
@pytest.mark.parametrize("algorithm", ["md5", "MD5-sess", "sha-256"])
236+
async def test_encode_algorithm_case_preservation_lowercase(
237+
digest_auth_mw: DigestAuthMiddleware,
238+
qop_challenge: DigestAuthChallenge,
239+
algorithm: str,
240+
) -> None:
241+
"""Test that lowercase/mixed-case algorithm is preserved in the response header."""
242+
# Create a challenge with the specific algorithm case
243+
challenge = qop_challenge.copy()
244+
challenge["algorithm"] = algorithm
245+
digest_auth_mw._challenge = challenge
246+
247+
header = await digest_auth_mw._encode(
248+
"GET", URL("http://example.com/resource"), b""
249+
)
250+
251+
# The algorithm in the response should match the exact case from the challenge
252+
assert f"algorithm={algorithm}" in header
253+
# Also verify it's not the uppercase version
254+
assert f"algorithm={algorithm.upper()}" not in header
255+
256+
214257
async def test_invalid_qop_rejected(
215258
digest_auth_mw: DigestAuthMiddleware, basic_challenge: DigestAuthChallenge
216259
) -> None:
@@ -1231,3 +1274,55 @@ def test_in_protection_space_multiple_spaces(
12311274
digest_auth_mw._in_protection_space(URL("http://example.com/secure")) is False
12321275
)
12331276
assert digest_auth_mw._in_protection_space(URL("http://example.com/other")) is False
1277+
1278+
1279+
async def test_case_sensitive_algorithm_server(
1280+
aiohttp_server: AiohttpServer,
1281+
) -> None:
1282+
"""Test authentication with a server that requires exact algorithm case matching.
1283+
1284+
This simulates servers like Prusa printers that expect the algorithm
1285+
to be returned with the exact same case as sent in the challenge.
1286+
"""
1287+
digest_auth_mw = DigestAuthMiddleware("testuser", "testpass")
1288+
request_count = 0
1289+
auth_algorithms: list[str] = []
1290+
1291+
async def handler(request: Request) -> Response:
1292+
nonlocal request_count
1293+
request_count += 1
1294+
1295+
if not (auth_header := request.headers.get(hdrs.AUTHORIZATION)):
1296+
# Send challenge with lowercase-sess algorithm (like Prusa)
1297+
challenge = 'Digest realm="Administrator", nonce="test123", qop="auth", algorithm="MD5-sess", opaque="xyz123"'
1298+
return Response(
1299+
status=401,
1300+
headers={"WWW-Authenticate": challenge},
1301+
text="Unauthorized",
1302+
)
1303+
1304+
# Extract algorithm from auth response
1305+
algo_match = re.search(r"algorithm=([^,\s]+)", auth_header)
1306+
assert algo_match is not None
1307+
auth_algorithms.append(algo_match.group(1))
1308+
1309+
# Case-sensitive server: only accept exact case match
1310+
assert "algorithm=MD5-sess" in auth_header
1311+
return Response(text="Success")
1312+
1313+
app = Application()
1314+
app.router.add_get("/api/test", handler)
1315+
server = await aiohttp_server(app)
1316+
1317+
async with (
1318+
ClientSession(middlewares=(digest_auth_mw,)) as session,
1319+
session.get(server.make_url("/api/test")) as resp,
1320+
):
1321+
assert resp.status == 200
1322+
text = await resp.text()
1323+
assert text == "Success"
1324+
1325+
# Verify the middleware preserved the exact algorithm case
1326+
assert request_count == 2 # Initial 401 + successful retry
1327+
assert len(auth_algorithms) == 1
1328+
assert auth_algorithms[0] == "MD5-sess" # Not "MD5-SESS"

0 commit comments

Comments
 (0)