Skip to content

Commit 935222a

Browse files
Merge branch 'master' into add-rate-limit-middleware
2 parents 0e90586 + aa29f21 commit 935222a

4 files changed

Lines changed: 143 additions & 31 deletions

File tree

‎CHANGES/8.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed ``DigestAuthMiddleware`` raising an ``IndexError`` on empty domain -- by :user:`Dreamsorcerer`.

‎CHANGES/9.bugfix.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed :class:`~aiohttp_client_middlewares.DigestAuthMiddleware` corrupting the
2+
``Digest`` challenge when a ``WWW-Authenticate`` response offered more than one
3+
authentication scheme -- by :user:`Dreamsorcerer`.

‎aiohttp_client_middlewares/digest_auth.py‎

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,27 +54,23 @@ class DigestAuthChallenge(TypedDict, total=False):
5454

5555
# Compile the regex pattern once at module level for performance
5656
_HEADER_PAIRS_PATTERN = re.compile(
57-
r'(?:^|\s|,\s*)(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))'
57+
r'(?:^|\s|,\s*)(\w+)(?:\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+)))?'
5858
if sys.version_info < (3, 11)
59-
else r'(?:^|\s|,\s*)((?>\w+))\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))'
60-
# +------------|--------|--|-|-|--|----|------|----|--||-----|-> Match valid start/sep
61-
# +--------|--|-|-|--|----|------|----|--||-----|-> alphanumeric key (atomic
62-
# | | | | | | | | || | group reduces backtracking)
63-
# +--|-|-|--|----|------|----|--||-----|-> maybe whitespace
64-
# | | | | | | | || |
65-
# +-|-|--|----|------|----|--||-----|-> = (delimiter)
66-
# +-|--|----|------|----|--||-----|-> maybe whitespace
67-
# | | | | | || |
68-
# +--|----|------|----|--||-----|-> group quoted or unquoted
69-
# | | | | || |
70-
# +----|------|----|--||-----|-> if quoted...
71-
# +------|----|--||-----|-> anything but " or \
72-
# +----|--||-----|-> escaped characters allowed
73-
# +--||-----|-> or can be empty string
74-
# || |
75-
# +|-----|-> if unquoted...
76-
# +-----|-> anything but , or <space>
77-
# +-> at least one char req'd
59+
else r'(?:^|\s|,\s*)((?>\w+))(?:\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+)))?'
60+
# +------------|--------|--|--||--|--|----|------|---|---||-----|-> Match valid start/sep
61+
# +--------|--|--||--|--|----|------|---|---||-----|-> alphanumeric key (atomic group reduces backtracking)
62+
# +--|--||--|--|----|------|---|---||-----|-> optional value; absent => bare auth-scheme token
63+
# +--||--|--|----|------|---|---||-----|-> maybe whitespace
64+
# +|--|--|----|------|---|---||-----|-> = (delimiter)
65+
# +--|--|----|------|---|---||-----|-> maybe whitespace
66+
# +--|----|------|---|---||-----|-> group quoted or unquoted
67+
# +----|------|---|---||-----|-> if quoted...
68+
# +------|---|---||-----|-> anything but " or \
69+
# +---|---||-----|-> escaped characters allowed
70+
# +---||-----|-> or can be empty string
71+
# +|-----|-> if unquoted...
72+
# +-----|-> anything but , or <space>
73+
# +-> at least one char req'd
7874
)
7975

8076

@@ -119,12 +115,17 @@ def unescape_quotes(value: str) -> str:
119115

120116
def parse_header_pairs(header: str) -> dict[str, str]:
121117
"""
122-
Parse key-value pairs from WWW-Authenticate or similar HTTP headers.
118+
Parse key-value pairs from the first challenge of a WWW-Authenticate header.
123119
124120
This function handles the complex format of WWW-Authenticate header values,
125121
supporting both quoted and unquoted values, proper handling of commas in
126122
quoted values, and whitespace variations per RFC 7616.
127123
124+
A single header may carry several challenges
125+
(https://www.rfc-editor.org/rfc/rfc7235#section-4.1). Parsing
126+
stops at the next auth-scheme token so a later challenge's parameters cannot
127+
overwrite the first challenge's values; a leading scheme token is skipped.
128+
128129
Examples of supported formats:
129130
- key1="value1", key2=value2
130131
- key1 = "value1" , key2="value, with, commas"
@@ -137,11 +138,21 @@ def parse_header_pairs(header: str) -> dict[str, str]:
137138
Returns:
138139
Dictionary mapping parameter names to their values
139140
"""
140-
return {
141-
stripped_key: unescape_quotes(quoted_val) if quoted_val else unquoted_val
142-
for key, quoted_val, unquoted_val in _HEADER_PAIRS_PATTERN.findall(header)
143-
if (stripped_key := key.strip())
144-
}
141+
pairs: dict[str, str] = {}
142+
for match in _HEADER_PAIRS_PATTERN.finditer(header):
143+
key = match.group(1)
144+
quoted_val, unquoted_val = match.group(2), match.group(3)
145+
if quoted_val is None and unquoted_val is None:
146+
# Bare token with no "=value": an auth-scheme name, not a parameter.
147+
# Skip a leading scheme; once parameters exist, a new scheme marks
148+
# the start of the next challenge, so stop here.
149+
if pairs:
150+
break
151+
continue
152+
pairs[key] = (
153+
unescape_quotes(quoted_val) if quoted_val is not None else unquoted_val
154+
)
155+
return pairs
145156

146157

147158
class DigestAuthMiddleware:
@@ -436,21 +447,23 @@ def _authenticate(self, response: ClientResponse) -> bool:
436447

437448
# Update protection space based on domain parameter or default to origin
438449
origin = response.url.origin()
450+
self._protection_space = []
439451

440452
if domain := self._challenge.get("domain"):
441453
# Parse space-separated list of URIs
442-
self._protection_space = []
443454
for uri in domain.split():
444455
# Remove quotes if present
445456
uri = uri.strip('"')
457+
if not uri:
458+
continue
446459
if uri.startswith("/"):
447460
# Path-absolute, relative to origin
448461
self._protection_space.append(str(origin.join(URL(uri))))
449462
else:
450463
# Absolute URI
451464
self._protection_space.append(str(URL(uri)))
452-
else:
453-
# No domain specified, protection space is entire origin
465+
466+
if not self._protection_space:
454467
self._protection_space = [str(origin)]
455468

456469
# Return True only if we found at least one challenge parameter

‎tests/test_digest_auth.py‎

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
)
487513
def 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+
15021596
async 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

Comments
 (0)