Skip to content

Commit 8e07118

Browse files
[3.15] gh-59515: Support multiple challenges in urllib Digest Auth (GH-160828) (#161160)
gh-59515: Support multiple challenges in urllib Digest Auth (GH-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) (cherry picked from commit 529d66c) Co-authored-by: Senthil Kumaran <senthil@python.org>
1 parent 0d43164 commit 8e07118

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
@@ -1916,6 +1916,120 @@ def test_unsupported_auth_digest_handler(self):
19161916
opener.add_handler(http_handler)
19171917
self.assertRaises(ValueError, opener.open, "http://www.example.com")
19181918

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