Skip to content

Commit 851cf9a

Browse files
miss-islingtonlkk7
andauthored
[3.12] gh-155694: Scope HTTPPasswordMgr credentials by URL scheme (GH-155696) (#155971)
gh-155694: Scope HTTPPasswordMgr credentials by URL scheme (GH-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 <lukaszlapinski7@gmail.com>
1 parent 58c86ab commit 851cf9a

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

952952
*uri* can be either a single URI, or a sequence of URIs. *realm*, *user* and
953953
*passwd* must be strings. This causes ``(user, passwd)`` to be used as
954-
authentication tokens when authentication for *realm* and a super-URI of any of
955-
the given URIs is given.
954+
authentication tokens when authentication for *realm* and a super-URI of any
955+
of the given URIs is given. If a URI includes a scheme, its credentials only
956+
match authentication URIs with the same scheme or no scheme. A URI without a
957+
scheme matches authentication URIs with any scheme.
958+
959+
.. versionchanged:: next
960+
Authentication credentials for URIs with a scheme are now scoped by
961+
that scheme.
956962

957963

958964
.. 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
@@ -271,6 +271,50 @@ def test_password_manager_default_port(self):
271271
self.assertEqual(find_user_pass("i", "http://j.example.com:80"),
272272
(None, None))
273273

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

275319
class MockOpener:
276320
addheaders = []
@@ -1754,6 +1798,18 @@ def test_basic_prior_auth_auto_send(self):
17541798
# expect request to be sent with auth header
17551799
self.assertTrue(http_handler.has_auth_header)
17561800

1801+
def test_basic_prior_auth_different_scheme(self):
1802+
pwd_manager = HTTPPasswordMgrWithPriorAuth()
1803+
auth_handler = HTTPBasicAuthHandler(pwd_manager)
1804+
auth_handler.add_password(
1805+
None, "https://example.com/", "user", "password",
1806+
is_authenticated=True)
1807+
1808+
request = Request("http://example.com/")
1809+
auth_handler.http_request(request)
1810+
1811+
self.assertFalse(request.has_header("Authorization"))
1812+
17571813
def test_basic_prior_auth_send_after_first_success(self):
17581814
# Auto send auth header after authentication is successful once
17591815

Lib/urllib/request.py

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

846846
def find_user_password(self, realm, authuri):
847847
domains = self.passwd.get(realm, {})
848848
for default_port in True, False:
849-
reduced_authuri = self.reduce_uri(authuri, default_port)
849+
reduced_authuri = self._reduce_uri_with_scheme(
850+
authuri, default_port)
850851
for uris, authinfo in domains.items():
851852
for uri in uris:
852-
if self.is_suburi(uri, reduced_authuri):
853+
if self._is_suburi_with_scheme(uri, reduced_authuri):
853854
return authinfo
854855
return None, None
855856

@@ -876,6 +877,17 @@ def reduce_uri(self, uri, default_port=True):
876877
authority = "%s:%d" % (host, dport)
877878
return authority, path
878879

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

922934
for default_port in True, False:
923935
for u in uri:
924-
reduced_uri = self.reduce_uri(u, default_port)
936+
reduced_uri = self._reduce_uri_with_scheme(u, default_port)
925937
self.authenticated[reduced_uri] = is_authenticated
926938

927939
def is_authenticated(self, authuri):
928940
for default_port in True, False:
929-
reduced_authuri = self.reduce_uri(authuri, default_port)
941+
reduced_authuri = self._reduce_uri_with_scheme(
942+
authuri, default_port)
930943
for uri in self.authenticated:
931-
if self.is_suburi(uri, reduced_authuri):
944+
if self._is_suburi_with_scheme(uri, reduced_authuri):
932945
return self.authenticated[uri]
933946

934947

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)