Skip to content

Commit 7ed5a4f

Browse files
[3.14] gh-59515: Support multiple challenges in urllib Digest Auth (GH-160828) (#161161)
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 86b2b3e commit 7ed5a4f

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

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

‎Lib/urllib/request.py‎

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

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

11101168
def retry_http_digest_auth(self, req, auth):
11111169
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)