Skip to content

Commit fc5a675

Browse files
lkk7mcepl
authored andcommitted
[CVE-2026-15806]: Scope HTTPPasswordMgr credentials by URL scheme
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. Fixes: bsc#1276223 (CVE-2026-15806) Fixes: gh#python#155694 From-PR: gh#python/cpython!155696 Patch: CVE-2026-15806-HTTPPasswordMgr-scheme.patch
1 parent 79e4d1c commit fc5a675

4 files changed

Lines changed: 88 additions & 8 deletions

File tree

‎Doc/library/urllib.request.rst‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,8 +896,14 @@ These methods are available on :class:`HTTPPasswordMgr` and
896896

897897
*uri* can be either a single URI, or a sequence of URIs. *realm*, *user* and
898898
*passwd* must be strings. This causes ``(user, passwd)`` to be used as
899-
authentication tokens when authentication for *realm* and a super-URI of any of
900-
the given URIs is given.
899+
authentication tokens when authentication for *realm* and a super-URI of any
900+
of the given URIs is given. If a URI includes a scheme, its credentials only
901+
match authentication URIs with the same scheme or no scheme. A URI without a
902+
scheme matches authentication URIs with any scheme.
903+
904+
.. versionchanged:: next
905+
Authentication credentials for URIs with a scheme are now scoped by
906+
that scheme.
901907

902908

903909
.. 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
@@ -248,6 +248,50 @@ def test_password_manager_default_port(self):
248248
self.assertEqual(find_user_pass("i", "http://j.example.com:80"),
249249
(None, None))
250250

251+
def test_password_manager_scheme(self):
252+
mgr = urllib.request.HTTPPasswordMgr()
253+
mgr.add_password(
254+
"realm", "https://example.com/", "user", "password")
255+
256+
self.assertEqual(
257+
mgr.find_user_password("realm", "https://example.com/"),
258+
("user", "password"))
259+
self.assertEqual(
260+
mgr.find_user_password("realm", "http://example.com/"),
261+
(None, None))
262+
# Support an authority without a scheme.
263+
self.assertEqual(
264+
mgr.find_user_password("realm", "example.com"),
265+
("user", "password"))
266+
# An authority without a scheme continues to match any scheme.
267+
mgr.add_password(
268+
"realm", "schemeless.example.com", "user", "password")
269+
for scheme in "http", "https":
270+
with self.subTest(scheme=scheme):
271+
self.assertEqual(
272+
mgr.find_user_password(
273+
"realm", f"{scheme}://schemeless.example.com/"),
274+
("user", "password"))
275+
276+
# A network-path reference also has no scheme.
277+
mgr.add_password(
278+
"realm", "//network-path.example.com/", "user", "password")
279+
self.assertEqual(
280+
mgr.find_user_password(
281+
"realm", "https://network-path.example.com/"),
282+
("user", "password"))
283+
284+
def test_password_manager_reduced_uri(self):
285+
mgr = urllib.request.HTTPPasswordMgr()
286+
287+
self.assertEqual(
288+
mgr.reduce_uri("http://example.com/path"),
289+
("example.com:80", "/path"))
290+
self.assertTrue(
291+
mgr.is_suburi(
292+
("example.com", "/path"),
293+
("example.com", "/path/subpath")))
294+
251295

252296
class MockOpener:
253297
addheaders = []
@@ -1654,6 +1698,18 @@ def test_basic_prior_auth_auto_send(self):
16541698
# expect request to be sent with auth header
16551699
self.assertTrue(http_handler.has_auth_header)
16561700

1701+
def test_basic_prior_auth_different_scheme(self):
1702+
pwd_manager = HTTPPasswordMgrWithPriorAuth()
1703+
auth_handler = HTTPBasicAuthHandler(pwd_manager)
1704+
auth_handler.add_password(
1705+
None, "https://example.com/", "user", "password",
1706+
is_authenticated=True)
1707+
1708+
request = Request("http://example.com/")
1709+
auth_handler.http_request(request)
1710+
1711+
self.assertFalse(request.has_header("Authorization"))
1712+
16571713
def test_basic_prior_auth_send_after_first_success(self):
16581714
# Auto send auth header after authentication is successful once
16591715

‎Lib/urllib/request.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -846,16 +846,18 @@ def add_password(self, realm, uri, user, passwd):
846846
self.passwd[realm] = {}
847847
for default_port in True, False:
848848
reduced_uri = tuple(
849-
[self.reduce_uri(u, default_port) for u in uri])
849+
self._reduce_uri_with_scheme(u, default_port) for u in uri)
850850
self.passwd[realm][reduced_uri] = (user, passwd)
851851

852852
def find_user_password(self, realm, authuri):
853853
domains = self.passwd.get(realm, {})
854854
for default_port in True, False:
855-
reduced_authuri = self.reduce_uri(authuri, default_port)
855+
reduced_authuri = self._reduce_uri_with_scheme(
856+
authuri, default_port)
856857
for uris, authinfo in domains.items():
857858
for uri in uris:
858-
if self.is_suburi(uri, reduced_authuri):
859+
if self._is_suburi_with_scheme(
860+
uri, reduced_authuri):
859861
return authinfo
860862
return None, None
861863

@@ -882,6 +884,17 @@ def reduce_uri(self, uri, default_port=True):
882884
authority = "%s:%d" % (host, dport)
883885
return authority, path
884886

887+
def _reduce_uri_with_scheme(self, uri, default_port=True):
888+
parts = urlsplit(uri)
889+
scheme = parts[0] if parts[1] else None
890+
return (scheme or None, *self.reduce_uri(uri, default_port))
891+
892+
def _is_suburi_with_scheme(self, base, test):
893+
if (base[0] is not None and test[0] is not None and
894+
base[0] != test[0]):
895+
return False
896+
return self.is_suburi(base[1:], test[1:])
897+
885898
def is_suburi(self, base, test):
886899
"""Check if test is below base in a URI tree
887900
@@ -927,14 +940,15 @@ def update_authenticated(self, uri, is_authenticated=False):
927940

928941
for default_port in True, False:
929942
for u in uri:
930-
reduced_uri = self.reduce_uri(u, default_port)
943+
reduced_uri = self._reduce_uri_with_scheme(u, default_port)
931944
self.authenticated[reduced_uri] = is_authenticated
932945

933946
def is_authenticated(self, authuri):
934947
for default_port in True, False:
935-
reduced_authuri = self.reduce_uri(authuri, default_port)
948+
reduced_authuri = self._reduce_uri_with_scheme(
949+
authuri, default_port)
936950
for uri in self.authenticated:
937-
if self.is_suburi(uri, reduced_authuri):
951+
if self._is_suburi_with_scheme(uri, reduced_authuri):
938952
return self.authenticated[uri]
939953

940954

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)