Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
rajorin committed Oct 7, 2026
commit 6c9f08f830c0f51fb3139c9fd642dbd9cdade2d0
48 changes: 48 additions & 0 deletions Lib/test/test_urllib2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

@maurycy maurycy Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing. It's possible I'm confused here!

I'm not sure if 407 from the proxy during an HTTPS tunnel even reaches http_error_407().

HTTPConnection._tunnel() raises OSError on a non-200 CONNECT reply:

cpython/Lib/http/client.py

Lines 1039 to 1041 in 2639fd6

if code != http.HTTPStatus.OK:
self.close()
raise OSError(f"Tunnel connection failed: {code} {message.strip()}")

I think this is #51540

Perhaps then:

Suggested change
# 407 from the proxy during an HTTPS tunnel must still be answered.
# gh-51540: a 407 to CONNECT never reaches this handler today, since
# http.client raises OSError on a non-200 CONNECT reply. If it ever
# did, the credentials would go to the proxy in CONNECT, not to the
# origin.

Another approach is removing this test. I believe it passes even without the PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right on both counts. HTTPConnection._tunnel() raises OSError for a non-200 CONNECT, and do_open turns that into URLError, so that 407 never reaches http_error_407. That's the existing gap in gh-51540.

The tunnel test also passes on main, so I removed it. _tunnel_host stays in the guard because an HTTPS proxy request does not set has_proxy(), and a 407 that does reach this handler should still be answered. On the retry, Proxy-Authorization is sent on CONNECT, not to the origin.

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
Expand Down
6 changes: 6 additions & 0 deletions Lib/urllib/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about making this comment a bit less verbose?

Suggested change
# 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shortened it to that line.

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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``.