diff --git a/Doc/library/urllib.request.rst b/Doc/library/urllib.request.rst index 95e4d2627c8b23b..ca383d8c40da91e 100644 --- a/Doc/library/urllib.request.rst +++ b/Doc/library/urllib.request.rst @@ -987,8 +987,14 @@ These methods are available on :class:`HTTPPasswordMgr` and *uri* can be either a single URI, or a sequence of URIs. *realm*, *user* and *passwd* must be strings. This causes ``(user, passwd)`` to be used as - authentication tokens when authentication for *realm* and a super-URI of any of - the given URIs is given. + authentication tokens when authentication for *realm* and a super-URI of any + of the given URIs is given. If a URI includes a scheme, its credentials only + match authentication URIs with the same scheme. A URI without a scheme + matches authentication URIs with any scheme. + + .. versionchanged:: next + Authentication credentials for URIs with a scheme are now scoped by + that scheme. .. method:: HTTPPasswordMgr.find_user_password(realm, authuri) diff --git a/Lib/test/test_urllib2.py b/Lib/test/test_urllib2.py index d2fd111f6d9de02..7efbc81a16096a6 100644 --- a/Lib/test/test_urllib2.py +++ b/Lib/test/test_urllib2.py @@ -270,6 +270,50 @@ def test_password_manager_default_port(self): self.assertEqual(find_user_pass("i", "http://j.example.com:80"), (None, None)) + def test_password_manager_scheme(self): + mgr = urllib.request.HTTPPasswordMgr() + mgr.add_password( + "realm", "https://example.com/", "user", "password") + + self.assertEqual( + mgr.find_user_password("realm", "https://example.com/"), + ("user", "password")) + self.assertEqual( + mgr.find_user_password("realm", "http://example.com/"), + (None, None)) + # Support an authority without a scheme. + self.assertEqual( + mgr.find_user_password("realm", "example.com"), + ("user", "password")) + # An authority without a scheme continues to match any scheme. + mgr.add_password( + "realm", "schemeless.example.com", "user", "password") + for scheme in "http", "https": + with self.subTest(scheme=scheme): + self.assertEqual( + mgr.find_user_password( + "realm", f"{scheme}://schemeless.example.com/"), + ("user", "password")) + + # A network-path reference also has no scheme. + mgr.add_password( + "realm", "//network-path.example.com/", "user", "password") + self.assertEqual( + mgr.find_user_password( + "realm", "https://network-path.example.com/"), + ("user", "password")) + + def test_password_manager_reduced_uri(self): + mgr = urllib.request.HTTPPasswordMgr() + + self.assertEqual( + mgr.reduce_uri("http://example.com/path"), + ("example.com:80", "/path")) + self.assertTrue( + mgr.is_suburi( + ("example.com", "/path"), + ("example.com", "/path/subpath"))) + class MockOpener: addheaders = [] @@ -1825,6 +1869,18 @@ def test_basic_prior_auth_auto_send(self): # expect request to be sent with auth header self.assertTrue(http_handler.has_auth_header) + def test_basic_prior_auth_different_scheme(self): + pwd_manager = HTTPPasswordMgrWithPriorAuth() + auth_handler = HTTPBasicAuthHandler(pwd_manager) + auth_handler.add_password( + None, "https://example.com/", "user", "password", + is_authenticated=True) + + request = Request("http://example.com/") + auth_handler.http_request(request) + + self.assertFalse(request.has_header("Authorization")) + def test_basic_prior_auth_send_after_first_success(self): # Auto send auth header after authentication is successful once diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py index 660301fef612588..9fa92659a255ed4 100644 --- a/Lib/urllib/request.py +++ b/Lib/urllib/request.py @@ -815,16 +815,17 @@ def add_password(self, realm, uri, user, passwd): self.passwd[realm] = {} for default_port in True, False: reduced_uri = tuple( - self.reduce_uri(u, default_port) for u in uri) + self._reduce_uri_with_scheme(u, default_port) for u in uri) self.passwd[realm][reduced_uri] = (user, passwd) def find_user_password(self, realm, authuri): domains = self.passwd.get(realm, {}) for default_port in True, False: - reduced_authuri = self.reduce_uri(authuri, default_port) + reduced_authuri = self._reduce_uri_with_scheme( + authuri, default_port) for uris, authinfo in domains.items(): for uri in uris: - if self.is_suburi(uri, reduced_authuri): + if self._is_suburi_with_scheme(uri, reduced_authuri): return authinfo return None, None @@ -851,6 +852,17 @@ def reduce_uri(self, uri, default_port=True): authority = "%s:%d" % (host, dport) return authority, path + def _reduce_uri_with_scheme(self, uri, default_port=True): + parts = urlsplit(uri) + scheme = parts[0] if parts[1] else None + return (scheme or None, *self.reduce_uri(uri, default_port)) + + def _is_suburi_with_scheme(self, base, test): + if (base[0] is not None and test[0] is not None and + base[0] != test[0]): + return False + return self.is_suburi(base[1:], test[1:]) + def is_suburi(self, base, test): """Check if test is below base in a URI tree @@ -896,14 +908,15 @@ def update_authenticated(self, uri, is_authenticated=False): for default_port in True, False: for u in uri: - reduced_uri = self.reduce_uri(u, default_port) + reduced_uri = self._reduce_uri_with_scheme(u, default_port) self.authenticated[reduced_uri] = is_authenticated def is_authenticated(self, authuri): for default_port in True, False: - reduced_authuri = self.reduce_uri(authuri, default_port) + reduced_authuri = self._reduce_uri_with_scheme( + authuri, default_port) for uri in self.authenticated: - if self.is_suburi(uri, reduced_authuri): + if self._is_suburi_with_scheme(uri, reduced_authuri): return self.authenticated[uri] diff --git a/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst b/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst new file mode 100644 index 000000000000000..dbc2119640702c9 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst @@ -0,0 +1,4 @@ +Fix :cve:`2026-15806` by scoping :class:`~urllib.request.HTTPPasswordMgr` +credentials to the URL scheme, preventing credentials stored for an HTTPS +URL from being used for a matching HTTP URL, while URIs without a scheme +continue to match any scheme.