Skip to content

Commit 529d66c

Browse files
authored
gh-59515: Support multiple challenges in urllib Digest Auth (#160828)
`HTTPDigestAuthHandler` and `ProxyDigestAuthHandler` only looked at the first `WWW-Authenticate` / `Proxy-Authenticate` header and took its first word as the scheme. As a result: * A Digest challenge was ignored if a Basic challenge came first, in the same header or in an earlier one. * An unsupported scheme listed first (for example `Kerberos, Basic realm="x"`) raised `ValueError`. Because the Digest handler runs before the Basic handler, that also blocked `HTTPBasicAuthHandler`. This PR changes only `AbstractDigestAuthHandler`. `AbstractBasicAuthHandler` already handles multiple challenges since GH-18284 and is not modified. * `http_error_auth_reqed()` now reads all headers (`get_all()`) and uses the first Digest challenge found. It raises `ValueError` only when neither Digest nor Basic is offered; otherwise it returns `None` so the next handler can try. * A new private `_parse_challenges()` splits a header into challenges per RFC 9110 §11.6.1. A comma-separated element that starts with a word not followed by `=` begins a new challenge. Its quote/escape handling matches `parse_http_list()`. It makes a single linear pass with no regex. * `retry_http_digest_auth()` is unchanged. It receives the original text of the Digest challenge, so escapes are not processed twice. For a single-challenge header the string is the same as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 9c0cfb1 commit 529d66c

3 files changed

Lines changed: 184 additions & 8 deletions

File tree

‎Lib/test/test_urllib2.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,120 @@ def test_unsupported_auth_digest_handler(self):
19371937
opener.add_handler(http_handler)
19381938
self.assertRaises(ValueError, opener.open, "http://www.example.com")
19391939

1940+
def check_digest_auth(self, headers, realm):
1941+
with self.subTest(headers=headers):
1942+
opener = OpenerDirector()
1943+
password_manager = MockPasswordManager()
1944+
auth_handler = urllib.request.HTTPDigestAuthHandler(
1945+
password_manager)
1946+
auth_handler.add_password(realm, "http://acme.example.com/",
1947+
"wile", "coyote")
1948+
body = '\r\n'.join(headers) + '\r\n\r\n'
1949+
http_handler = MockHTTPHandlerRedirect(401, body)
1950+
opener.add_handler(auth_handler)
1951+
opener.add_handler(http_handler)
1952+
opener.open("http://acme.example.com/protected")
1953+
self.assertEqual(password_manager.target_realm, realm)
1954+
auth = http_handler.requests[1].unredirected_hdrs["Authorization"]
1955+
self.assertStartsWith(auth, "Digest ")
1956+
self.assertIn(f'realm="{realm}"', auth)
1957+
self.assertIn('nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"', auth)
1958+
self.assertIn('opaque="5ccc069c403ebaf9f0171e9517f40e41"', auth)
1959+
1960+
def test_digest_auth_multiple_challenges(self):
1961+
realm = "digest@example.com"
1962+
digest = (f'Digest realm="{realm}", '
1963+
f'qop="auth, auth-int", '
1964+
f'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", '
1965+
f'opaque="5ccc069c403ebaf9f0171e9517f40e41"')
1966+
basic = 'Basic realm="basic@example.com"'
1967+
other = 'Otherscheme xxx'
1968+
1969+
# Multiple challenges per header.
1970+
for challenges in (
1971+
digest,
1972+
f'{basic}, {digest}',
1973+
f'{digest}, {basic}',
1974+
f'{other}, {digest}',
1975+
f'{basic}, {digest}, {other}',
1976+
):
1977+
self.check_digest_auth([f'WWW-Authenticate: {challenges}'], realm)
1978+
1979+
# Multiple headers: one challenge per header.
1980+
for challenges in (
1981+
[basic, digest],
1982+
[digest, basic],
1983+
[other, digest],
1984+
):
1985+
headers = [f'WWW-Authenticate: {challenge}'
1986+
for challenge in challenges]
1987+
self.check_digest_auth(headers, realm)
1988+
1989+
def test_digest_auth_handler_ignores_basic_challenge(self):
1990+
# Leave Basic challenges to HTTPBasicAuthHandler, even when an
1991+
# unsupported scheme is present as well.
1992+
for headers in (
1993+
'WWW-Authenticate: Basic realm="x"\r\n\r\n',
1994+
'WWW-Authenticate: Kerberos, Basic realm="x"\r\n\r\n',
1995+
'WWW-Authenticate: Kerberos\r\n'
1996+
'WWW-Authenticate: Basic realm="x"\r\n\r\n',
1997+
):
1998+
with self.subTest(headers=headers):
1999+
opener = OpenerDirector()
2000+
opener.add_handler(urllib.request.HTTPDigestAuthHandler(None))
2001+
opener.add_handler(MockHTTPHandlerRedirect(401, headers))
2002+
self.assertIsNone(opener.open("http://www.example.com"))
2003+
2004+
def test_digest_auth_parse_challenges(self):
2005+
parse = AbstractDigestAuthHandler()._parse_challenges
2006+
self.assertEqual(list(parse('')), [])
2007+
self.assertEqual(list(parse('Digest realm="a"')),
2008+
[('Digest', 'Digest realm="a"')])
2009+
# Commas and quotes inside a quoted string do not split challenges.
2010+
self.assertEqual(
2011+
list(parse(r'Digest realm="a, Basic realm=\"b\"", nonce="n", '
2012+
r'Basic realm="c"')),
2013+
[('Digest', r'Digest realm="a, Basic realm=\"b\"", nonce="n"'),
2014+
('Basic', 'Basic realm="c"')])
2015+
# Whitespace around "=" does not start a new challenge.
2016+
self.assertEqual(
2017+
list(parse('Digest realm = "a", nonce = "n", Basic realm="b"')),
2018+
[('Digest', 'Digest realm = "a", nonce = "n"'),
2019+
('Basic', 'Basic realm="b"')])
2020+
self.assertEqual(
2021+
list(parse('Negotiate, Bearer abc==, Digest realm="a",, ')),
2022+
[('Negotiate', 'Negotiate'), ('Bearer', 'Bearer abc=='),
2023+
('Digest', 'Digest realm="a"')])
2024+
2025+
def test_proxy_digest_auth_multiple_challenges(self):
2026+
realm = "digest@example.com"
2027+
digest = (f'Digest realm="{realm}", '
2028+
f'qop="auth", '
2029+
f'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"')
2030+
basic = 'Basic realm="basic@example.com"'
2031+
for headers in (
2032+
[f'Proxy-Authenticate: {basic}, {digest}'],
2033+
[f'Proxy-Authenticate: {basic}', f'Proxy-Authenticate: {digest}'],
2034+
):
2035+
with self.subTest(headers=headers):
2036+
opener = OpenerDirector()
2037+
opener.add_handler(urllib.request.ProxyHandler(
2038+
dict(http="proxy.example.com:3128")))
2039+
password_manager = MockPasswordManager()
2040+
auth_handler = urllib.request.ProxyDigestAuthHandler(
2041+
password_manager)
2042+
auth_handler.add_password(realm, "proxy.example.com:3128",
2043+
"wile", "coyote")
2044+
body = '\r\n'.join(headers) + '\r\n\r\n'
2045+
http_handler = MockHTTPHandlerRedirect(407, body)
2046+
opener.add_handler(auth_handler)
2047+
opener.add_handler(http_handler)
2048+
opener.open("http://acme.example.com/protected")
2049+
self.assertEqual(password_manager.target_realm, realm)
2050+
auth = http_handler.requests[1].unredirected_hdrs[
2051+
"Proxy-authorization"]
2052+
self.assertStartsWith(auth, "Digest ")
2053+
self.assertIn(f'realm="{realm}"', auth)
19402054
def test_digest_auth_failure_code(self):
19412055
# gh-53852: a failed proxy digest auth must report 407, not 401.
19422056
headers = http.client.HTTPMessage()

‎Lib/urllib/request.py‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,8 +1085,58 @@ def __init__(self, passwd=None):
10851085
def reset_retry_count(self):
10861086
self.retried = 0
10871087

1088+
def _parse_challenges(self, header):
1089+
# Yield (scheme, challenge) for each challenge in a WWW-Authenticate
1090+
# header value. Commas separate both challenges and the auth-params
1091+
# of a challenge, so an element which starts with a token that is not
1092+
# followed by "=" starts a new challenge (RFC 9110, section 11.6.1).
1093+
#
1094+
# For example, the header value
1095+
#
1096+
# Basic realm="a", Digest realm="b", nonce="n"
1097+
#
1098+
# is split on commas into the elements 'Basic realm="a"',
1099+
# 'Digest realm="b"' and 'nonce="n"'. "Basic" and "Digest" are not
1100+
# followed by "=", so they start challenges; 'nonce="n"' continues
1101+
# the Digest one. This yields:
1102+
#
1103+
# ('Basic', 'Basic realm="a"')
1104+
# ('Digest', 'Digest realm="b", nonce="n"')
1105+
elements = []
1106+
start = 0
1107+
quote = escape = False
1108+
for i, c in enumerate(header):
1109+
if escape:
1110+
escape = False
1111+
elif quote:
1112+
if c == '\\':
1113+
escape = True
1114+
elif c == '"':
1115+
quote = False
1116+
elif c == '"':
1117+
quote = True
1118+
elif c == ',':
1119+
elements.append((start, i))
1120+
start = i + 1
1121+
elements.append((start, len(header)))
1122+
1123+
scheme = begin = end = None
1124+
for elt_start, elt_end in elements:
1125+
words = header[elt_start:elt_end].split(None, 1)
1126+
if not words:
1127+
continue
1128+
rest = words[1] if len(words) > 1 else ''
1129+
if '=' not in words[0] and not rest.startswith('='):
1130+
if scheme is not None:
1131+
yield scheme, header[begin:end].strip()
1132+
scheme = words[0]
1133+
begin = elt_start
1134+
end = elt_end
1135+
if scheme is not None:
1136+
yield scheme, header[begin:end].strip()
1137+
10881138
def http_error_auth_reqed(self, auth_header, host, req, headers):
1089-
authreq = headers.get(auth_header, None)
1139+
authreqs = headers.get_all(auth_header)
10901140
if self.retried > 5:
10911141
# Don't fail endlessly - if we failed once, we'll probably
10921142
# fail a second time. Hm. Unless the Password Manager is
@@ -1101,13 +1151,21 @@ def http_error_auth_reqed(self, auth_header, host, req, headers):
11011151
headers, None)
11021152
else:
11031153
self.retried += 1
1104-
if authreq:
1105-
scheme = authreq.split()[0]
1106-
if scheme.lower() == 'digest':
1107-
return self.retry_http_digest_auth(req, authreq)
1108-
elif scheme.lower() != 'basic':
1109-
raise ValueError("AbstractDigestAuthHandler does not support"
1110-
" the following scheme: '%s'" % scheme)
1154+
if not authreqs:
1155+
return None
1156+
unsupported = None
1157+
basic_found = False
1158+
for authreq in authreqs:
1159+
for scheme, challenge in self._parse_challenges(authreq):
1160+
if scheme.lower() == 'digest':
1161+
return self.retry_http_digest_auth(req, challenge)
1162+
if scheme.lower() == 'basic':
1163+
basic_found = True
1164+
elif unsupported is None:
1165+
unsupported = scheme
1166+
if unsupported is not None and not basic_found:
1167+
raise ValueError("AbstractDigestAuthHandler does not support"
1168+
" the following scheme: '%s'" % unsupported)
11111169

11121170
def retry_http_digest_auth(self, req, auth):
11131171
token, challenge = auth.split(' ', 1)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:class:`urllib.request.HTTPDigestAuthHandler` and
2+
:class:`urllib.request.ProxyDigestAuthHandler` now parse all
3+
``WWW-Authenticate`` (or ``Proxy-Authenticate``) headers and accept
4+
multiple challenges per header: the first Digest challenge is used.

0 commit comments

Comments
 (0)