Skip to content

Commit b18449c

Browse files
committed
gh-155694: Scope HTTPPasswordMgr credentials by URL scheme
1 parent 1c9521f commit b18449c

4 files changed

Lines changed: 87 additions & 8 deletions

File tree

Doc/library/urllib.request.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -987,8 +987,14 @@ These methods are available on :class:`HTTPPasswordMgr` and
987987

988988
*uri* can be either a single URI, or a sequence of URIs. *realm*, *user* and
989989
*passwd* must be strings. This causes ``(user, passwd)`` to be used as
990-
authentication tokens when authentication for *realm* and a super-URI of any of
991-
the given URIs is given.
990+
authentication tokens when authentication for *realm* and a super-URI of any
991+
of the given URIs is given. If a URI includes a scheme, its credentials only
992+
match authentication URIs with the same scheme. A URI without a scheme
993+
matches authentication URIs with any scheme.
994+
995+
.. versionchanged:: next
996+
Authentication credentials for URIs with a scheme are now scoped by
997+
that scheme.
992998

993999

9941000
.. method:: HTTPPasswordMgr.find_user_password(realm, authuri)

Lib/test/test_urllib2.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,50 @@ def test_password_manager_default_port(self):
270270
self.assertEqual(find_user_pass("i", "http://j.example.com:80"),
271271
(None, None))
272272

273+
def test_password_manager_scheme(self):
274+
mgr = urllib.request.HTTPPasswordMgr()
275+
mgr.add_password(
276+
"realm", "https://example.com/", "user", "password")
277+
278+
self.assertEqual(
279+
mgr.find_user_password("realm", "https://example.com/"),
280+
("user", "password"))
281+
self.assertEqual(
282+
mgr.find_user_password("realm", "http://example.com/"),
283+
(None, None))
284+
# Support an authority without a scheme.
285+
self.assertEqual(
286+
mgr.find_user_password("realm", "example.com"),
287+
("user", "password"))
288+
# An authority without a scheme continues to match any scheme.
289+
mgr.add_password(
290+
"realm", "schemeless.example.com", "user", "password")
291+
for scheme in "http", "https":
292+
with self.subTest(scheme=scheme):
293+
self.assertEqual(
294+
mgr.find_user_password(
295+
"realm", f"{scheme}://schemeless.example.com/"),
296+
("user", "password"))
297+
298+
# A network-path reference also has no scheme.
299+
mgr.add_password(
300+
"realm", "//network-path.example.com/", "user", "password")
301+
self.assertEqual(
302+
mgr.find_user_password(
303+
"realm", "https://network-path.example.com/"),
304+
("user", "password"))
305+
306+
def test_password_manager_reduced_uri(self):
307+
mgr = urllib.request.HTTPPasswordMgr()
308+
309+
self.assertEqual(
310+
mgr.reduce_uri("http://example.com/path"),
311+
("example.com:80", "/path"))
312+
self.assertTrue(
313+
mgr.is_suburi(
314+
("example.com", "/path"),
315+
("example.com", "/path/subpath")))
316+
273317

274318
class MockOpener:
275319
addheaders = []
@@ -1825,6 +1869,18 @@ def test_basic_prior_auth_auto_send(self):
18251869
# expect request to be sent with auth header
18261870
self.assertTrue(http_handler.has_auth_header)
18271871

1872+
def test_basic_prior_auth_different_scheme(self):
1873+
pwd_manager = HTTPPasswordMgrWithPriorAuth()
1874+
auth_handler = HTTPBasicAuthHandler(pwd_manager)
1875+
auth_handler.add_password(
1876+
None, "https://example.com/", "user", "password",
1877+
is_authenticated=True)
1878+
1879+
request = Request("http://example.com/")
1880+
auth_handler.http_request(request)
1881+
1882+
self.assertFalse(request.has_header("Authorization"))
1883+
18281884
def test_basic_prior_auth_send_after_first_success(self):
18291885
# Auto send auth header after authentication is successful once
18301886

Lib/urllib/request.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -815,16 +815,17 @@ def add_password(self, realm, uri, user, passwd):
815815
self.passwd[realm] = {}
816816
for default_port in True, False:
817817
reduced_uri = tuple(
818-
self.reduce_uri(u, default_port) for u in uri)
818+
self._reduce_uri_with_scheme(u, default_port) for u in uri)
819819
self.passwd[realm][reduced_uri] = (user, passwd)
820820

821821
def find_user_password(self, realm, authuri):
822822
domains = self.passwd.get(realm, {})
823823
for default_port in True, False:
824-
reduced_authuri = self.reduce_uri(authuri, default_port)
824+
reduced_authuri = self._reduce_uri_with_scheme(
825+
authuri, default_port)
825826
for uris, authinfo in domains.items():
826827
for uri in uris:
827-
if self.is_suburi(uri, reduced_authuri):
828+
if self._is_suburi_with_scheme(uri, reduced_authuri):
828829
return authinfo
829830
return None, None
830831

@@ -851,6 +852,17 @@ def reduce_uri(self, uri, default_port=True):
851852
authority = "%s:%d" % (host, dport)
852853
return authority, path
853854

855+
def _reduce_uri_with_scheme(self, uri, default_port=True):
856+
parts = urlsplit(uri)
857+
scheme = parts[0] if parts[1] else None
858+
return (scheme or None, *self.reduce_uri(uri, default_port))
859+
860+
def _is_suburi_with_scheme(self, base, test):
861+
if (base[0] is not None and test[0] is not None and
862+
base[0] != test[0]):
863+
return False
864+
return self.is_suburi(base[1:], test[1:])
865+
854866
def is_suburi(self, base, test):
855867
"""Check if test is below base in a URI tree
856868
@@ -896,14 +908,15 @@ def update_authenticated(self, uri, is_authenticated=False):
896908

897909
for default_port in True, False:
898910
for u in uri:
899-
reduced_uri = self.reduce_uri(u, default_port)
911+
reduced_uri = self._reduce_uri_with_scheme(u, default_port)
900912
self.authenticated[reduced_uri] = is_authenticated
901913

902914
def is_authenticated(self, authuri):
903915
for default_port in True, False:
904-
reduced_authuri = self.reduce_uri(authuri, default_port)
916+
reduced_authuri = self._reduce_uri_with_scheme(
917+
authuri, default_port)
905918
for uri in self.authenticated:
906-
if self.is_suburi(uri, reduced_authuri):
919+
if self._is_suburi_with_scheme(uri, reduced_authuri):
907920
return self.authenticated[uri]
908921

909922

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix :cve:`2026-15806` by scoping :class:`~urllib.request.HTTPPasswordMgr`
2+
credentials to the URL scheme, preventing credentials stored for an HTTPS
3+
URL from being used for a matching HTTP URL, while URIs without a scheme
4+
continue to match any scheme.

0 commit comments

Comments
 (0)