From 95e57c3cfe9b6f9a9e70d067afeeff16aaa503b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=81ukasz?= <lukaszlapinski7@gmail.com>
Date: Mon, 17 Aug 2026 21:39:16 +0200
Subject: [PATCH 1/3] gh-155694: Scope HTTPPasswordMgr credentials by URL
 scheme (GH-155696)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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 a7bb524fef61f77ede01f660ffbd591e1d5837ce)

Co-authored-by: Łukasz <lukaszlapinski7@gmail.com>
---
 Doc/library/urllib.request.rst                | 10 +++-
 Lib/test/test_urllib2.py                      | 56 +++++++++++++++++++
 Lib/urllib/request.py                         | 25 +++++++--
 ...-07-31-16-20-17.gh-issue-155694.SsxlKG.rst |  4 ++
 4 files changed, 87 insertions(+), 8 deletions(-)
 create mode 100644 Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst

Index: Python-3.11.16/Doc/library/urllib.request.rst
===================================================================
--- Python-3.11.16.orig/Doc/library/urllib.request.rst	2026-08-13 01:03:19.000000000 +0200
+++ Python-3.11.16/Doc/library/urllib.request.rst	2026-09-10 20:27:18.832162876 +0200
@@ -939,8 +939,14 @@
 
    *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)
Index: Python-3.11.16/Lib/test/test_urllib2.py
===================================================================
--- Python-3.11.16.orig/Lib/test/test_urllib2.py	2026-09-10 20:26:40.010207640 +0200
+++ Python-3.11.16/Lib/test/test_urllib2.py	2026-09-10 20:27:18.832540748 +0200
@@ -271,6 +271,50 @@
         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 = []
@@ -1714,6 +1758,18 @@
         # 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
 
Index: Python-3.11.16/Lib/urllib/request.py
===================================================================
--- Python-3.11.16.orig/Lib/urllib/request.py	2026-09-10 20:26:40.450781742 +0200
+++ Python-3.11.16/Lib/urllib/request.py	2026-09-10 20:27:18.832947575 +0200
@@ -844,16 +844,17 @@
             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
 
@@ -880,6 +881,17 @@
                 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
 
@@ -925,14 +937,15 @@
 
         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]
 
 
Index: Python-3.11.16/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ Python-3.11.16/Misc/NEWS.d/next/Security/2026-07-31-16-20-17.gh-issue-155694.SsxlKG.rst	2026-09-10 20:27:18.833718347 +0200
@@ -0,0 +1,4 @@
+Fix `CVE-2026-15806 <https://www.cve.org/CVERecord?id=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.
