https://github.com/python/cpython/commit/641be42bb07921ba0f8bffe228b1dc706b092ef6
commit: 641be42bb07921ba0f8bffe228b1dc706b092ef6
branch: 3.15
author: Miss Islington (bot) <[email protected]>
committer: hugovk <[email protected]>
date: 2026-08-19T05:15:34+03:00
summary:

[3.15] gh-155694: Scope HTTPPasswordMgr credentials by URL scheme (GH-155696) 
(#155968)

Co-authored-by: Ɓukasz <[email protected]>

files:
A Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst
M Doc/library/urllib.request.rst
M Lib/test/test_urllib2.py
M Lib/urllib/request.py

diff --git a/Doc/library/urllib.request.rst b/Doc/library/urllib.request.rst
index 95e4d2627c8b23b..9274a0c88ac4c86 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 or no 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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to