Skip to content

Commit 69cdeeb

Browse files
bpo-39503: CVE-2020-8492: Fix AbstractBasicAuthHandler (GH-18284) (GH-19304)
The AbstractBasicAuthHandler class of the urllib.request module uses an inefficient regular expression which can be exploited by an attacker to cause a denial of service. Fix the regex to prevent the catastrophic backtracking. Vulnerability reported by Ben Caller and Matt Schwager. AbstractBasicAuthHandler of urllib.request now parses all WWW-Authenticate HTTP headers and accepts multiple challenges per header: use the realm of the first Basic challenge. Co-Authored-By: Serhiy Storchaka <[email protected]> (cherry picked from commit 0b297d4)
1 parent ebeabb5 commit 69cdeeb

File tree

4 files changed

+115
-52
lines changed

4 files changed

+115
-52
lines changed

Lib/test/test_urllib2.py

+57-33
Original file line numberDiff line numberDiff line change
@@ -1445,40 +1445,64 @@ def test_osx_proxy_bypass(self):
14451445
bypass = {'exclude_simple': True, 'exceptions': []}
14461446
self.assertTrue(_proxy_bypass_macosx_sysconf('test', bypass))
14471447

1448-
def test_basic_auth(self, quote_char='"'):
1449-
opener = OpenerDirector()
1450-
password_manager = MockPasswordManager()
1451-
auth_handler = urllib.request.HTTPBasicAuthHandler(password_manager)
1452-
realm = "ACME Widget Store"
1453-
http_handler = MockHTTPHandler(
1454-
401, 'WWW-Authenticate: Basic realm=%s%s%s\r\n\r\n' %
1455-
(quote_char, realm, quote_char))
1456-
opener.add_handler(auth_handler)
1457-
opener.add_handler(http_handler)
1458-
self._test_basic_auth(opener, auth_handler, "Authorization",
1459-
realm, http_handler, password_manager,
1460-
"http://acme.example.com/protected",
1461-
"http://acme.example.com/protected",
1462-
)
1463-
1464-
def test_basic_auth_with_single_quoted_realm(self):
1465-
self.test_basic_auth(quote_char="'")
1466-
1467-
def test_basic_auth_with_unquoted_realm(self):
1468-
opener = OpenerDirector()
1469-
password_manager = MockPasswordManager()
1470-
auth_handler = urllib.request.HTTPBasicAuthHandler(password_manager)
1471-
realm = "ACME Widget Store"
1472-
http_handler = MockHTTPHandler(
1473-
401, 'WWW-Authenticate: Basic realm=%s\r\n\r\n' % realm)
1474-
opener.add_handler(auth_handler)
1475-
opener.add_handler(http_handler)
1476-
with self.assertWarns(UserWarning):
1448+
def check_basic_auth(self, headers, realm):
1449+
with self.subTest(realm=realm, headers=headers):
1450+
opener = OpenerDirector()
1451+
password_manager = MockPasswordManager()
1452+
auth_handler = urllib.request.HTTPBasicAuthHandler(password_manager)
1453+
body = '\r\n'.join(headers) + '\r\n\r\n'
1454+
http_handler = MockHTTPHandler(401, body)
1455+
opener.add_handler(auth_handler)
1456+
opener.add_handler(http_handler)
14771457
self._test_basic_auth(opener, auth_handler, "Authorization",
1478-
realm, http_handler, password_manager,
1479-
"http://acme.example.com/protected",
1480-
"http://acme.example.com/protected",
1481-
)
1458+
realm, http_handler, password_manager,
1459+
"http://acme.example.com/protected",
1460+
"http://acme.example.com/protected")
1461+
1462+
def test_basic_auth(self):
1463+
1464+
realm2 = "[email protected]"
1465+
basic = f'Basic realm="{realm}"'
1466+
basic2 = f'Basic realm="{realm2}"'
1467+
other_no_realm = 'Otherscheme xxx'
1468+
digest = (f'Digest realm="{realm2}", '
1469+
f'qop="auth, auth-int", '
1470+
f'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", '
1471+
f'opaque="5ccc069c403ebaf9f0171e9517f40e41"')
1472+
for realm_str in (
1473+
# test "quote" and 'quote'
1474+
f'Basic realm="{realm}"',
1475+
f"Basic realm='{realm}'",
1476+
1477+
# charset is ignored
1478+
f'Basic realm="{realm}", charset="UTF-8"',
1479+
1480+
# Multiple challenges per header
1481+
f'{basic}, {basic2}',
1482+
f'{basic}, {other_no_realm}',
1483+
f'{other_no_realm}, {basic}',
1484+
f'{basic}, {digest}',
1485+
f'{digest}, {basic}',
1486+
):
1487+
headers = [f'WWW-Authenticate: {realm_str}']
1488+
self.check_basic_auth(headers, realm)
1489+
1490+
# no quote: expect a warning
1491+
with support.check_warnings(("Basic Auth Realm was unquoted",
1492+
UserWarning)):
1493+
headers = [f'WWW-Authenticate: Basic realm={realm}']
1494+
self.check_basic_auth(headers, realm)
1495+
1496+
# Multiple headers: one challenge per header.
1497+
# Use the first Basic realm.
1498+
for challenges in (
1499+
[basic, basic2],
1500+
[basic, digest],
1501+
[digest, basic],
1502+
):
1503+
headers = [f'WWW-Authenticate: {challenge}'
1504+
for challenge in challenges]
1505+
self.check_basic_auth(headers, realm)
14821506

14831507
def test_proxy_basic_auth(self):
14841508
opener = OpenerDirector()

Lib/urllib/request.py

+50-19
Original file line numberDiff line numberDiff line change
@@ -945,8 +945,15 @@ class AbstractBasicAuthHandler:
945945

946946
# allow for double- and single-quoted realm values
947947
# (single quotes are a violation of the RFC, but appear in the wild)
948-
rx = re.compile('(?:.*,)*[ \t]*([^ \t]+)[ \t]+'
949-
'realm=(["\']?)([^"\']*)\\2', re.I)
948+
rx = re.compile('(?:^|,)' # start of the string or ','
949+
'[ \t]*' # optional whitespaces
950+
'([^ \t]+)' # scheme like "Basic"
951+
'[ \t]+' # mandatory whitespaces
952+
# realm=xxx
953+
# realm='xxx'
954+
# realm="xxx"
955+
'realm=(["\']?)([^"\']*)\\2',
956+
re.I)
950957

951958
# XXX could pre-emptively send auth info already accepted (RFC 2617,
952959
# end of section 2, and section 1.2 immediately after "credentials"
@@ -958,27 +965,51 @@ def __init__(self, password_mgr=None):
958965
self.passwd = password_mgr
959966
self.add_password = self.passwd.add_password
960967

968+
def _parse_realm(self, header):
969+
# parse WWW-Authenticate header: accept multiple challenges per header
970+
found_challenge = False
971+
for mo in AbstractBasicAuthHandler.rx.finditer(header):
972+
scheme, quote, realm = mo.groups()
973+
if quote not in ['"', "'"]:
974+
warnings.warn("Basic Auth Realm was unquoted",
975+
UserWarning, 3)
976+
977+
yield (scheme, realm)
978+
979+
found_challenge = True
980+
981+
if not found_challenge:
982+
if header:
983+
scheme = header.split()[0]
984+
else:
985+
scheme = ''
986+
yield (scheme, None)
987+
961988
def http_error_auth_reqed(self, authreq, host, req, headers):
962989
# host may be an authority (without userinfo) or a URL with an
963990
# authority
964-
# XXX could be multiple headers
965-
authreq = headers.get(authreq, None)
991+
headers = headers.get_all(authreq)
992+
if not headers:
993+
# no header found
994+
return
966995

967-
if authreq:
968-
scheme = authreq.split()[0]
969-
if scheme.lower() != 'basic':
970-
raise ValueError("AbstractBasicAuthHandler does not"
971-
" support the following scheme: '%s'" %
972-
scheme)
973-
else:
974-
mo = AbstractBasicAuthHandler.rx.search(authreq)
975-
if mo:
976-
scheme, quote, realm = mo.groups()
977-
if quote not in ['"',"'"]:
978-
warnings.warn("Basic Auth Realm was unquoted",
979-
UserWarning, 2)
980-
if scheme.lower() == 'basic':
981-
return self.retry_http_basic_auth(host, req, realm)
996+
unsupported = None
997+
for header in headers:
998+
for scheme, realm in self._parse_realm(header):
999+
if scheme.lower() != 'basic':
1000+
unsupported = scheme
1001+
continue
1002+
1003+
if realm is not None:
1004+
# Use the first matching Basic challenge.
1005+
# Ignore following challenges even if they use the Basic
1006+
# scheme.
1007+
return self.retry_http_basic_auth(host, req, realm)
1008+
1009+
if unsupported is not None:
1010+
raise ValueError("AbstractBasicAuthHandler does not "
1011+
"support the following scheme: %r"
1012+
% (scheme,))
9821013

9831014
def retry_http_basic_auth(self, host, req, realm):
9841015
user, pw = self.passwd.find_user_password(realm, host)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:class:`~urllib.request.AbstractBasicAuthHandler` of :mod:`urllib.request`
2+
now parses all WWW-Authenticate HTTP headers and accepts multiple challenges
3+
per header: use the realm of the first Basic challenge.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CVE-2020-8492: The :class:`~urllib.request.AbstractBasicAuthHandler` class of the
2+
:mod:`urllib.request` module uses an inefficient regular expression which can
3+
be exploited by an attacker to cause a denial of service. Fix the regex to
4+
prevent the catastrophic backtracking. Vulnerability reported by Ben Caller
5+
and Matt Schwager.

0 commit comments

Comments
 (0)