Skip to content

Commit 13ec2a0

Browse files
committed
gh-59515: Support multiple challenges in urllib Digest Auth
`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 312222f commit 13ec2a0

3 files changed

Lines changed: 185 additions & 8 deletions

File tree

‎Lib/test/test_urllib2.py‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,121 @@ def test_unsupported_auth_digest_handler(self):
17951795
opener.add_handler(http_handler)
17961796
self.assertRaises(ValueError, opener.open, "http://www.example.com")
17971797

1798+
def check_digest_auth(self, headers, realm):
1799+
with self.subTest(headers=headers):
1800+
opener = OpenerDirector()
1801+
password_manager = MockPasswordManager()
1802+
auth_handler = urllib.request.HTTPDigestAuthHandler(
1803+
password_manager)
1804+
auth_handler.add_password(realm, "http://acme.example.com/",
1805+
"wile", "coyote")
1806+
body = '\r\n'.join(headers) + '\r\n\r\n'
1807+
http_handler = MockHTTPHandlerRedirect(401, body)
1808+
opener.add_handler(auth_handler)
1809+
opener.add_handler(http_handler)
1810+
opener.open("http://acme.example.com/protected")
1811+
self.assertEqual(password_manager.target_realm, realm)
1812+
auth = http_handler.requests[1].unredirected_hdrs["Authorization"]
1813+
self.assertStartsWith(auth, "Digest ")
1814+
self.assertIn(f'realm="{realm}"', auth)
1815+
self.assertIn('nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"', auth)
1816+
self.assertIn('opaque="5ccc069c403ebaf9f0171e9517f40e41"', auth)
1817+
1818+
def test_digest_auth_multiple_challenges(self):
1819+
realm = "digest@example.com"
1820+
digest = (f'Digest realm="{realm}", '
1821+
f'qop="auth, auth-int", '
1822+
f'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", '
1823+
f'opaque="5ccc069c403ebaf9f0171e9517f40e41"')
1824+
basic = 'Basic realm="basic@example.com"'
1825+
other = 'Otherscheme xxx'
1826+
1827+
# Multiple challenges per header.
1828+
for challenges in (
1829+
digest,
1830+
f'{basic}, {digest}',
1831+
f'{digest}, {basic}',
1832+
f'{other}, {digest}',
1833+
f'{basic}, {digest}, {other}',
1834+
):
1835+
self.check_digest_auth([f'WWW-Authenticate: {challenges}'], realm)
1836+
1837+
# Multiple headers: one challenge per header.
1838+
for challenges in (
1839+
[basic, digest],
1840+
[digest, basic],
1841+
[other, digest],
1842+
):
1843+
headers = [f'WWW-Authenticate: {challenge}'
1844+
for challenge in challenges]
1845+
self.check_digest_auth(headers, realm)
1846+
1847+
def test_digest_auth_handler_ignores_basic_challenge(self):
1848+
# Leave Basic challenges to HTTPBasicAuthHandler, even when an
1849+
# unsupported scheme is present as well.
1850+
for headers in (
1851+
'WWW-Authenticate: Basic realm="x"\r\n\r\n',
1852+
'WWW-Authenticate: Kerberos, Basic realm="x"\r\n\r\n',
1853+
'WWW-Authenticate: Kerberos\r\n'
1854+
'WWW-Authenticate: Basic realm="x"\r\n\r\n',
1855+
):
1856+
with self.subTest(headers=headers):
1857+
opener = OpenerDirector()
1858+
opener.add_handler(urllib.request.HTTPDigestAuthHandler(None))
1859+
opener.add_handler(MockHTTPHandlerRedirect(401, headers))
1860+
self.assertIsNone(opener.open("http://www.example.com"))
1861+
1862+
def test_digest_auth_parse_challenges(self):
1863+
parse = AbstractDigestAuthHandler()._parse_challenges
1864+
self.assertEqual(list(parse('')), [])
1865+
self.assertEqual(list(parse('Digest realm="a"')),
1866+
[('Digest', 'Digest realm="a"')])
1867+
# Commas and quotes inside a quoted string do not split challenges.
1868+
self.assertEqual(
1869+
list(parse(r'Digest realm="a, Basic realm=\"b\"", nonce="n", '
1870+
r'Basic realm="c"')),
1871+
[('Digest', r'Digest realm="a, Basic realm=\"b\"", nonce="n"'),
1872+
('Basic', 'Basic realm="c"')])
1873+
# Whitespace around "=" does not start a new challenge.
1874+
self.assertEqual(
1875+
list(parse('Digest realm = "a", nonce = "n", Basic realm="b"')),
1876+
[('Digest', 'Digest realm = "a", nonce = "n"'),
1877+
('Basic', 'Basic realm="b"')])
1878+
self.assertEqual(
1879+
list(parse('Negotiate, Bearer abc==, Digest realm="a",, ')),
1880+
[('Negotiate', 'Negotiate'), ('Bearer', 'Bearer abc=='),
1881+
('Digest', 'Digest realm="a"')])
1882+
1883+
def test_proxy_digest_auth_multiple_challenges(self):
1884+
realm = "digest@example.com"
1885+
digest = (f'Digest realm="{realm}", '
1886+
f'qop="auth", '
1887+
f'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"')
1888+
basic = 'Basic realm="basic@example.com"'
1889+
for headers in (
1890+
[f'Proxy-Authenticate: {basic}, {digest}'],
1891+
[f'Proxy-Authenticate: {basic}', f'Proxy-Authenticate: {digest}'],
1892+
):
1893+
with self.subTest(headers=headers):
1894+
opener = OpenerDirector()
1895+
opener.add_handler(urllib.request.ProxyHandler(
1896+
dict(http="proxy.example.com:3128")))
1897+
password_manager = MockPasswordManager()
1898+
auth_handler = urllib.request.ProxyDigestAuthHandler(
1899+
password_manager)
1900+
auth_handler.add_password(realm, "proxy.example.com:3128",
1901+
"wile", "coyote")
1902+
body = '\r\n'.join(headers) + '\r\n\r\n'
1903+
http_handler = MockHTTPHandlerRedirect(407, body)
1904+
opener.add_handler(auth_handler)
1905+
opener.add_handler(http_handler)
1906+
opener.open("http://acme.example.com/protected")
1907+
self.assertEqual(password_manager.target_realm, realm)
1908+
auth = http_handler.requests[1].unredirected_hdrs[
1909+
"Proxy-authorization"]
1910+
self.assertStartsWith(auth, "Digest ")
1911+
self.assertIn(f'realm="{realm}"', auth)
1912+
17981913
def test_unsupported_auth_basic_handler(self):
17991914
# While using BasicAuthHandler
18001915
opener = OpenerDirector()

‎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
@@ -1097,13 +1147,21 @@ def http_error_auth_reqed(self, auth_header, host, req, headers):
10971147
headers, None)
10981148
else:
10991149
self.retried += 1
1100-
if authreq:
1101-
scheme = authreq.split()[0]
1102-
if scheme.lower() == 'digest':
1103-
return self.retry_http_digest_auth(req, authreq)
1104-
elif scheme.lower() != 'basic':
1105-
raise ValueError("AbstractDigestAuthHandler does not support"
1106-
" the following scheme: '%s'" % scheme)
1150+
if not authreqs:
1151+
return None
1152+
unsupported = None
1153+
basic_found = False
1154+
for authreq in authreqs:
1155+
for scheme, challenge in self._parse_challenges(authreq):
1156+
if scheme.lower() == 'digest':
1157+
return self.retry_http_digest_auth(req, challenge)
1158+
if scheme.lower() == 'basic':
1159+
basic_found = True
1160+
elif unsupported is None:
1161+
unsupported = scheme
1162+
if unsupported is not None and not basic_found:
1163+
raise ValueError("AbstractDigestAuthHandler does not support"
1164+
" the following scheme: '%s'" % unsupported)
11071165

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