Skip to content

Commit da85b16

Browse files
miss-islingtonlkk7Eclips4
authored andcommitted
[3.10] pythongh-155694: Scope HTTPPasswordMgr credentials by URL scheme (pythonGH-155696) (python#155973)
* pythongh-155694: Scope HTTPPasswordMgr credentials by URL scheme (pythonGH-155696) Credentials stored for an https:// URI were also matched against the corresponding http:// URI, since `reduce_uri()` discards the scheme. `HTTPPasswordMgr` and `HTTPPasswordMgrWithPriorAuth` now compare the scheme too; URIs registered without a scheme still match any scheme. (cherry picked from commit a7bb524) Co-authored-by: Łukasz <[email protected]> * Apply suggestion from @Eclips4 Unfortunately, too old Sphinx * Empty commit to triger CI --------- Co-authored-by: Łukasz <[email protected]> Co-authored-by: Kirill Podoprigora <[email protected]> (cherry picked from commit dac88d8)
1 parent 766767c commit da85b16

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
@@ -917,8 +917,14 @@ These methods are available on :class:`HTTPPasswordMgr` and
917917

918918
*uri* can be either a single URI, or a sequence of URIs. *realm*, *user* and
919919
*passwd* must be strings. This causes ``(user, passwd)`` to be used as
920-
authentication tokens when authentication for *realm* and a super-URI of any of
921-
the given URIs is given.
920+
authentication tokens when authentication for *realm* and a super-URI of any
921+
of the given URIs is given. If a URI includes a scheme, its credentials only
922+
match authentication URIs with the same scheme or no scheme. A URI without a
923+
scheme matches authentication URIs with any scheme.
924+
925+
.. versionchanged:: next
926+
Authentication credentials for URIs with a scheme are now scoped by
927+
that scheme.
922928

923929

924930
.. 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
@@ -267,6 +267,50 @@ def test_password_manager_default_port(self):
267267
self.assertEqual(find_user_pass("i", "http://j.example.com:80"),
268268
(None, None))
269269

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

271315
class MockOpener:
272316
addheaders = []
@@ -1710,6 +1754,18 @@ def test_basic_prior_auth_auto_send(self):
17101754
# expect request to be sent with auth header
17111755
self.assertTrue(http_handler.has_auth_header)
17121756

1757+
def test_basic_prior_auth_different_scheme(self):
1758+
pwd_manager = HTTPPasswordMgrWithPriorAuth()
1759+
auth_handler = HTTPBasicAuthHandler(pwd_manager)
1760+
auth_handler.add_password(
1761+
None, "https://example.com/", "user", "password",
1762+
is_authenticated=True)
1763+
1764+
request = Request("http://example.com/")
1765+
auth_handler.http_request(request)
1766+
1767+
self.assertFalse(request.has_header("Authorization"))
1768+
17131769
def test_basic_prior_auth_send_after_first_success(self):
17141770
# Auto send auth header after authentication is successful once
17151771

‎Lib/urllib/request.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -842,16 +842,17 @@ def add_password(self, realm, uri, user, passwd):
842842
self.passwd[realm] = {}
843843
for default_port in True, False:
844844
reduced_uri = tuple(
845-
self.reduce_uri(u, default_port) for u in uri)
845+
self._reduce_uri_with_scheme(u, default_port) for u in uri)
846846
self.passwd[realm][reduced_uri] = (user, passwd)
847847

848848
def find_user_password(self, realm, authuri):
849849
domains = self.passwd.get(realm, {})
850850
for default_port in True, False:
851-
reduced_authuri = self.reduce_uri(authuri, default_port)
851+
reduced_authuri = self._reduce_uri_with_scheme(
852+
authuri, default_port)
852853
for uris, authinfo in domains.items():
853854
for uri in uris:
854-
if self.is_suburi(uri, reduced_authuri):
855+
if self._is_suburi_with_scheme(uri, reduced_authuri):
855856
return authinfo
856857
return None, None
857858

@@ -878,6 +879,17 @@ def reduce_uri(self, uri, default_port=True):
878879
authority = "%s:%d" % (host, dport)
879880
return authority, path
880881

882+
def _reduce_uri_with_scheme(self, uri, default_port=True):
883+
parts = urlsplit(uri)
884+
scheme = parts[0] if parts[1] else None
885+
return (scheme or None, *self.reduce_uri(uri, default_port))
886+
887+
def _is_suburi_with_scheme(self, base, test):
888+
if (base[0] is not None and test[0] is not None and
889+
base[0] != test[0]):
890+
return False
891+
return self.is_suburi(base[1:], test[1:])
892+
881893
def is_suburi(self, base, test):
882894
"""Check if test is below base in a URI tree
883895
@@ -923,14 +935,15 @@ def update_authenticated(self, uri, is_authenticated=False):
923935

924936
for default_port in True, False:
925937
for u in uri:
926-
reduced_uri = self.reduce_uri(u, default_port)
938+
reduced_uri = self._reduce_uri_with_scheme(u, default_port)
927939
self.authenticated[reduced_uri] = is_authenticated
928940

929941
def is_authenticated(self, authuri):
930942
for default_port in True, False:
931-
reduced_authuri = self.reduce_uri(authuri, default_port)
943+
reduced_authuri = self._reduce_uri_with_scheme(
944+
authuri, default_port)
932945
for uri in self.authenticated:
933-
if self.is_suburi(uri, reduced_authuri):
946+
if self._is_suburi_with_scheme(uri, reduced_authuri):
934947
return self.authenticated[uri]
935948

936949

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix `CVE-2026-15806 <https://www.cve.org/CVERecord?id=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)