@@ -123,6 +123,18 @@ def mock_md5_digest() -> Generator[mock.MagicMock, None, None]:
123123 True ,
124124 {"realm" : "" , "nonce" : "abc" , "qop" : "auth" },
125125 ),
126+ # Multi-scheme header: a second challenge (Basic) in the same
127+ # WWW-Authenticate value must not overwrite the Digest realm/nonce.
128+ # https://www.rfc-editor.org/rfc/rfc7235#section-4.1
129+ (
130+ 401 ,
131+ {
132+ "www-authenticate" : 'Digest realm="protected", nonce="n1", '
133+ 'qop="auth", Basic realm="other"'
134+ },
135+ True ,
136+ {"realm" : "protected" , "nonce" : "n1" , "qop" : "auth" },
137+ ),
126138 # Non-401 status
127139 (200 , {}, False , {}), # No challenge should be set
128140 ],
@@ -471,6 +483,18 @@ async def test_digest_response_exact_match(
471483 ),
472484 # Empty header
473485 ("" , {}),
486+ # Multi-scheme header: parsing stops at the next auth-scheme so a later
487+ # challenge cannot overwrite the first's values.
488+ # https://www.rfc-editor.org/rfc/rfc7235#section-4.1
489+ (
490+ 'realm="protected", nonce="n1", qop="auth", Basic realm="other"' ,
491+ {"realm" : "protected" , "nonce" : "n1" , "qop" : "auth" },
492+ ),
493+ # Multi-scheme header including the leading scheme token
494+ (
495+ 'Digest realm="protected", nonce="n1", Basic realm="other"' ,
496+ {"realm" : "protected" , "nonce" : "n1" },
497+ ),
474498 ],
475499 ids = [
476500 "fully_quoted_header" ,
@@ -482,6 +506,8 @@ async def test_digest_response_exact_match(
482506 "escaped_quotes" ,
483507 "single_quotes_as_regular_chars" ,
484508 "empty_header" ,
509+ "multi_scheme_second_challenge_ignored" ,
510+ "multi_scheme_with_leading_scheme" ,
485511 ],
486512)
487513def test_parse_header_pairs (header : str , expected_result : dict [str , str ]) -> None :
@@ -1499,6 +1525,74 @@ def test_in_protection_space_multiple_spaces(
14991525 assert digest_auth_mw ._in_protection_space (URL ("http://example.com/other" )) is False
15001526
15011527
1528+ @pytest .mark .parametrize (
1529+ "domain_value" ,
1530+ (r'"\""' , '"' ),
1531+ ids = ("quoted_escaped_quote" , "bare_quote" ),
1532+ )
1533+ def test_authenticate_domain_only_quote_does_not_poison_protection_space (
1534+ digest_auth_mw : DigestAuthMiddleware ,
1535+ domain_value : str ,
1536+ ) -> None :
1537+ response = mock .create_autospec (ClientResponse , spec_set = True , instance = True )
1538+ response .status = 401
1539+ response .url = URL ("http://example.com/resource" )
1540+ response .headers = {
1541+ "www-authenticate" : f'Digest realm="test", nonce="abc", domain={ domain_value } '
1542+ }
1543+
1544+ assert digest_auth_mw ._authenticate (response ) is True
1545+ assert digest_auth_mw ._challenge ["domain" ] == '"'
1546+ assert digest_auth_mw ._protection_space == ["http://example.com" ]
1547+ assert "" not in digest_auth_mw ._protection_space
1548+ # Must not raise IndexError and must still scope to the anchor origin.
1549+ assert digest_auth_mw ._in_protection_space (URL ("http://example.com/other" )) is True
1550+ assert digest_auth_mw ._in_protection_space (URL ("http://other.com/x" )) is False
1551+
1552+
1553+ async def test_double_quote_domain_does_not_break_future_requests (
1554+ aiohttp_server : AiohttpServer ,
1555+ ) -> None :
1556+ """End-to-end regression for a ``domain`` directive of just a double quote.
1557+
1558+ The first request triggers the challenge and authenticates on retry. Before
1559+ the fix, the bogus ``domain`` left an empty string in the protection space,
1560+ so the next request's preemptive-auth check raised ``IndexError``.
1561+ """
1562+ digest_auth_mw = DigestAuthMiddleware ("user" , "pass" , preemptive = True )
1563+ auth_headers : list [str | None ] = []
1564+
1565+ async def handler (request : Request ) -> Response :
1566+ auth_headers .append (request .headers .get (hdrs .AUTHORIZATION ))
1567+ if request .headers .get (hdrs .AUTHORIZATION ) is None :
1568+ challenge = (
1569+ 'Digest realm="test", nonce="abc123", qop="auth", '
1570+ 'algorithm=MD5, domain="\\ ""'
1571+ )
1572+ return Response (
1573+ status = 401 ,
1574+ headers = {"WWW-Authenticate" : challenge },
1575+ text = "Unauthorized" ,
1576+ )
1577+ return Response (text = "OK" )
1578+
1579+ app = Application ()
1580+ app .router .add_get ("/path1" , handler )
1581+ app .router .add_get ("/path2" , handler )
1582+ server = await aiohttp_server (app )
1583+
1584+ async with ClientSession (middlewares = (digest_auth_mw ,)) as session :
1585+ async with session .get (server .make_url ("/path1" )) as resp :
1586+ assert resp .status == 200
1587+ # Previously raised IndexError inside the preemptive-auth check.
1588+ async with session .get (server .make_url ("/path2" )) as resp :
1589+ assert resp .status == 200
1590+
1591+ assert auth_headers [0 ] is None # First request: no auth, gets challenge
1592+ assert auth_headers [1 ] is not None # Retry carries the digest response
1593+ assert auth_headers [2 ] is not None # Second request: preemptive auth
1594+
1595+
15021596async def test_case_sensitive_algorithm_server (
15031597 aiohttp_server : AiohttpServer ,
15041598) -> None :
@@ -1565,5 +1659,6 @@ def test_regex_performance() -> None:
15651659 f"Regex took { elapsed * 1000 :.1f} ms, "
15661660 f"expected <{ REGEX_TIME_THRESHOLD_SECONDS * 1000 :.0f} ms - potential ReDoS issue"
15671661 )
1568- # This example shouldn't produce a match either.
1569- assert not matches
1662+ # The lone run of word characters matches as a bare auth-scheme token
1663+ # with empty value groups, so it never becomes a key=value pair.
1664+ assert all (not quoted and not unquoted for _ , quoted , unquoted in matches )
0 commit comments