From 6c9f08f830c0f51fb3139c9fd642dbd9cdade2d0 Mon Sep 17 00:00:00 2001 From: Rinku Rajole Date: Wed, 7 Oct 2026 13:38:33 +0530 Subject: [PATCH 1/2] gh-158907: Do not send proxy credentials to a direct origin on 407 ProxyBasicAuthHandler looked up passwords with the scheme-less origin host, so an HTTP 407 could match credentials stored for HTTPS and retry in cleartext. --- Lib/test/test_urllib2.py | 48 +++++++++++++++++++ Lib/urllib/request.py | 6 +++ ...-10-07-13-30-00.gh-issue-158907.k4Qm2p.rst | 4 ++ 3 files changed, 58 insertions(+) create mode 100644 Misc/NEWS.d/next/Security/2026-10-07-13-30-00.gh-issue-158907.k4Qm2p.rst diff --git a/Lib/test/test_urllib2.py b/Lib/test/test_urllib2.py index 7efbc81a16096a..8e4d16a94d610e 100644 --- a/Lib/test/test_urllib2.py +++ b/Lib/test/test_urllib2.py @@ -1735,6 +1735,54 @@ def test_proxy_basic_auth(self): "proxy.example.com:3128", ) + def test_proxy_basic_auth_ignores_direct_407(self): + # gh-158907: a direct HTTP origin that returns 407 must not receive + # credentials registered for the same authority over HTTPS. + opener = OpenerDirector() + opener.add_handler(urllib.request.ProxyHandler({})) + password_manager = urllib.request.HTTPPasswordMgr() + auth_handler = urllib.request.ProxyBasicAuthHandler(password_manager) + realm = "test-realm" + http_handler = MockHTTPHandlerRedirect( + 407, 'Proxy-Authenticate: Basic realm="%s"\r\n\r\n' % realm) + opener.add_handler(auth_handler) + opener.add_handler(http_handler) + + password_manager.add_password( + realm, "https://example.com/", "victim-user", "victim-secret") + opener.open("http://example.com/resource") + + self.assertEqual(len(http_handler.requests), 1) + self.assertFalse( + http_handler.requests[0].has_header("Proxy-authorization")) + + def test_proxy_basic_auth_https_tunnel_still_authenticates(self): + # 407 from the proxy during an HTTPS tunnel must still be answered. + class MockHTTPSHandlerRedirect(MockHTTPHandlerRedirect): + def https_open(self, req): + return self.http_open(req) + + opener = OpenerDirector() + opener.add_handler(urllib.request.ProxyHandler( + dict(https="proxy.example.com:3128"))) + password_manager = urllib.request.HTTPPasswordMgr() + auth_handler = urllib.request.ProxyBasicAuthHandler(password_manager) + realm = "ACME Networks" + http_handler = MockHTTPSHandlerRedirect( + 407, 'Proxy-Authenticate: Basic realm="%s"\r\n\r\n' % realm) + opener.add_handler(auth_handler) + opener.add_handler(http_handler) + + password_manager.add_password( + realm, "proxy.example.com:3128", "wile", "coyote") + opener.open("https://acme.example.com/protected") + + self.assertEqual(len(http_handler.requests), 2) + self.assertFalse( + http_handler.requests[0].has_header("Proxy-authorization")) + self.assertTrue( + http_handler.requests[1].has_header("Proxy-authorization")) + def test_basic_and_digest_auth_handlers(self): # HTTPDigestAuthHandler raised an exception if it couldn't handle a 40* # response (https://bugs.python.org/issue1479302), where it should instead diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py index 59ed4a7140d59c..4b686ec4ea5e02 100644 --- a/Lib/urllib/request.py +++ b/Lib/urllib/request.py @@ -1048,6 +1048,12 @@ class ProxyBasicAuthHandler(AbstractBasicAuthHandler, BaseHandler): auth_header = 'Proxy-authorization' def http_error_407(self, req, fp, code, msg, headers): + # A direct origin is not a proxy. req.host has no scheme, so a + # password stored for https://HOST/ would match and be sent in + # cleartext on the retry (gh-158907). HTTPS tunnels set + # _tunnel_host instead of making has_proxy() true. + if not req.has_proxy() and not req._tunnel_host: + return None # http_error_auth_reqed requires that there is no userinfo component in # authority. Assume there isn't one, since urllib.request does not (and # should not, RFC 3986 s. 3.2.1) support requests for URLs containing diff --git a/Misc/NEWS.d/next/Security/2026-10-07-13-30-00.gh-issue-158907.k4Qm2p.rst b/Misc/NEWS.d/next/Security/2026-10-07-13-30-00.gh-issue-158907.k4Qm2p.rst new file mode 100644 index 00000000000000..9b635298c3560c --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-10-07-13-30-00.gh-issue-158907.k4Qm2p.rst @@ -0,0 +1,4 @@ +:class:`~urllib.request.ProxyBasicAuthHandler` no longer sends credentials +in response to ``407`` from a direct origin. A scheme-less lookup against +the origin host could match passwords stored for an HTTPS URL and retry +the cleartext request with ``Proxy-Authorization``. From b9bdbcce84c0cb6ff46adb856e9654960dd9f3c7 Mon Sep 17 00:00:00 2001 From: Rinku Rajole Date: Wed, 7 Oct 2026 16:20:14 +0530 Subject: [PATCH 2/2] gh-158907: Shorten the handler comment and drop the tunnel test A CONNECT 407 never reaches http_error_407, and the tunnel test passed without this change. --- Lib/test/test_urllib2.py | 27 --------------------------- Lib/urllib/request.py | 5 +---- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/Lib/test/test_urllib2.py b/Lib/test/test_urllib2.py index 8e4d16a94d610e..fd57cc83921c1b 100644 --- a/Lib/test/test_urllib2.py +++ b/Lib/test/test_urllib2.py @@ -1756,33 +1756,6 @@ def test_proxy_basic_auth_ignores_direct_407(self): self.assertFalse( http_handler.requests[0].has_header("Proxy-authorization")) - def test_proxy_basic_auth_https_tunnel_still_authenticates(self): - # 407 from the proxy during an HTTPS tunnel must still be answered. - class MockHTTPSHandlerRedirect(MockHTTPHandlerRedirect): - def https_open(self, req): - return self.http_open(req) - - opener = OpenerDirector() - opener.add_handler(urllib.request.ProxyHandler( - dict(https="proxy.example.com:3128"))) - password_manager = urllib.request.HTTPPasswordMgr() - auth_handler = urllib.request.ProxyBasicAuthHandler(password_manager) - realm = "ACME Networks" - http_handler = MockHTTPSHandlerRedirect( - 407, 'Proxy-Authenticate: Basic realm="%s"\r\n\r\n' % realm) - opener.add_handler(auth_handler) - opener.add_handler(http_handler) - - password_manager.add_password( - realm, "proxy.example.com:3128", "wile", "coyote") - opener.open("https://acme.example.com/protected") - - self.assertEqual(len(http_handler.requests), 2) - self.assertFalse( - http_handler.requests[0].has_header("Proxy-authorization")) - self.assertTrue( - http_handler.requests[1].has_header("Proxy-authorization")) - def test_basic_and_digest_auth_handlers(self): # HTTPDigestAuthHandler raised an exception if it couldn't handle a 40* # response (https://bugs.python.org/issue1479302), where it should instead diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py index 4b686ec4ea5e02..83e1d6f51f76d4 100644 --- a/Lib/urllib/request.py +++ b/Lib/urllib/request.py @@ -1048,10 +1048,7 @@ class ProxyBasicAuthHandler(AbstractBasicAuthHandler, BaseHandler): auth_header = 'Proxy-authorization' def http_error_407(self, req, fp, code, msg, headers): - # A direct origin is not a proxy. req.host has no scheme, so a - # password stored for https://HOST/ would match and be sent in - # cleartext on the retry (gh-158907). HTTPS tunnels set - # _tunnel_host instead of making has_proxy() true. + # gh-158907: a 407 from a direct origin is not a proxy challenge if not req.has_proxy() and not req._tunnel_host: return None # http_error_auth_reqed requires that there is no userinfo component in