Skip to content

Commit 9f03afd

Browse files
orsenthilclaude
andauthored
gh-51540: Let proxy auth handlers answer a 407 to CONNECT for https URLs (#161596)
urllib.request could not authenticate with a proxy for an https URL using ProxyBasicAuthHandler or ProxyDigestAuthHandler: http.client raised a plain OSError when the proxy answered the CONNECT request with 407, so the http_error_407 handlers never ran. * http.client: add ProxyTunnelError, an OSError subclass carrying the code, reason and headers of the proxy's response. It holds no socket and can be pickled. * urllib.request: do_open() passes a 407 to CONNECT on to the error handlers, so the request is retried with Proxy-Authorization on the CONNECT. Other CONNECT failures still raise URLError. * Request.set_proxy() keeps the tunnel when a request is re-opened, so the retried request still sends the path, not the absolute URL. * ProxyDigestAuthHandler computes the digest over the CONNECT request (CONNECT host:port) when tunnelling. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 043ddef commit 9f03afd

8 files changed

Lines changed: 339 additions & 7 deletions

File tree

‎Doc/library/http.client.rst‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,29 @@ The following exceptions are raised as appropriate:
230230
Previously, :exc:`BadStatusLine`\ ``('')`` was raised.
231231

232232

233+
.. exception:: ProxyTunnelError
234+
235+
A subclass of :exc:`OSError`. Raised when the proxy server answers the
236+
CONNECT request sent for :meth:`HTTPConnection.set_tunnel` with a status
237+
other than 200.
238+
239+
.. attribute:: code
240+
241+
The HTTP status code of the proxy's response, such as ``407``.
242+
243+
.. attribute:: reason
244+
245+
The reason phrase of the proxy's response.
246+
247+
.. attribute:: headers
248+
249+
The headers of the proxy's response, as an
250+
:class:`http.client.HTTPMessage` instance.
251+
252+
.. versionadded:: next
253+
Previously, :exc:`OSError` was raised.
254+
255+
233256
The constants defined in this module are:
234257

235258
.. data:: HTTP_PORT
@@ -398,6 +421,9 @@ HTTPConnection Objects
398421
one will be automatically generated and transmitted if not provided in
399422
the headers argument.
400423

424+
.. versionchanged:: next
425+
:exc:`ProxyTunnelError` is raised if the proxy rejects the CONNECT request.
426+
401427

402428
.. method:: HTTPConnection.get_proxy_response_headers()
403429

‎Doc/library/urllib.request.rst‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,10 @@ The following classes are provided:
445445
:ref:`http-password-mgr` for information on the interface that must be
446446
supported.
447447

448+
.. versionchanged:: next
449+
Also handles a proxy that requires authentication for the CONNECT
450+
request used to tunnel ``https`` URLs.
451+
448452

449453
.. class:: AbstractDigestAuthHandler(password_mgr=None)
450454

@@ -482,6 +486,10 @@ The following classes are provided:
482486
:ref:`http-password-mgr` for information on the interface that must be
483487
supported.
484488

489+
.. versionchanged:: next
490+
Also handles a proxy that requires authentication for the CONNECT
491+
request used to tunnel ``https`` URLs.
492+
485493

486494
.. class:: HTTPHandler()
487495

@@ -1102,6 +1110,14 @@ ProxyBasicAuthHandler Objects
11021110

11031111
Retry the request with authentication information, if available.
11041112

1113+
For an ``https`` URL, the request goes through a tunnel that the proxy
1114+
sets up in reply to a CONNECT request. A 407 response can then come
1115+
either from the proxy, rejecting the CONNECT, or from the origin server,
1116+
through the tunnel. Both are handled the same way: the request is retried
1117+
with the credentials for the proxy, and those credentials are sent only in
1118+
the CONNECT request, never to the origin server. If the origin server
1119+
keeps answering 407, :exc:`~urllib.error.HTTPError` is raised.
1120+
11051121

11061122
.. _abstract-digest-auth-handler:
11071123

@@ -1141,6 +1157,14 @@ ProxyDigestAuthHandler Objects
11411157

11421158
Retry the request with authentication information, if available.
11431159

1160+
For an ``https`` URL, the request goes through a tunnel that the proxy
1161+
sets up in reply to a CONNECT request. A 407 response can then come
1162+
either from the proxy, rejecting the CONNECT, or from the origin server,
1163+
through the tunnel. Both are handled the same way: the request is retried
1164+
with the credentials for the proxy, and those credentials are sent only in
1165+
the CONNECT request, never to the origin server. If the origin server
1166+
keeps answering 407, :exc:`~urllib.error.HTTPError` is raised.
1167+
11441168

11451169
.. _http-handler-objects:
11461170

‎Doc/whatsnew/3.16.rst‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,16 @@ gzip
508508
(Contributed by Marin Misur in :gh:`91372`.)
509509

510510

511+
http.client
512+
-----------
513+
514+
* Add the :exc:`~http.client.ProxyTunnelError` exception, a subclass of
515+
:exc:`OSError` raised when a proxy rejects the CONNECT request sent for
516+
:meth:`~http.client.HTTPConnection.set_tunnel`. It exposes the status
517+
code, reason and headers of the proxy's response.
518+
(Contributed by Senthil Kumaran in :gh:`51540`.)
519+
520+
511521
imaplib
512522
-------
513523

@@ -956,6 +966,18 @@ unicodedata
956966
* The Unicode database has been updated to Unicode 18.0.0.
957967

958968

969+
urllib.request
970+
--------------
971+
972+
* :class:`~urllib.request.ProxyBasicAuthHandler` and
973+
:class:`~urllib.request.ProxyDigestAuthHandler` now authenticate with a
974+
proxy that answers the CONNECT request for an ``https`` URL with
975+
``407 Proxy Authentication Required``. When no credentials are available,
976+
:exc:`~urllib.error.HTTPError` with code 407 is raised instead of
977+
:exc:`~urllib.error.URLError`.
978+
(Contributed by Senthil Kumaran in :gh:`51540`.)
979+
980+
959981
uuid
960982
----
961983

‎Lib/http/client.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@
8787
"UnknownTransferEncoding", "UnimplementedFileMode",
8888
"IncompleteRead", "InvalidURL", "ImproperConnectionState",
8989
"CannotSendRequest", "CannotSendHeader", "ResponseNotReady",
90-
"BadStatusLine", "LineTooLong", "RemoteDisconnected", "error",
91-
"responses"]
90+
"BadStatusLine", "LineTooLong", "RemoteDisconnected",
91+
"ProxyTunnelError", "error", "responses"]
9292

9393
HTTP_PORT = 80
9494
HTTPS_PORT = 443
@@ -1045,7 +1045,8 @@ def _tunnel(self):
10451045

10461046
if code != http.HTTPStatus.OK:
10471047
self.close()
1048-
raise OSError(f"Tunnel connection failed: {code} {message.strip()}")
1048+
raise ProxyTunnelError(code, message.strip(),
1049+
_parse_header_lines(self._raw_proxy_headers))
10491050

10501051
finally:
10511052
response.close()
@@ -1617,5 +1618,15 @@ def __init__(self, *pos, **kw):
16171618
BadStatusLine.__init__(self, "")
16181619
ConnectionResetError.__init__(self, *pos, **kw)
16191620

1621+
class ProxyTunnelError(OSError):
1622+
def __init__(self, code, reason, headers):
1623+
OSError.__init__(self, f"Tunnel connection failed: {code} {reason}")
1624+
self.code = code
1625+
self.reason = reason
1626+
self.headers = headers
1627+
1628+
def __reduce__(self):
1629+
return self.__class__, (self.code, self.reason, self.headers)
1630+
16201631
# for backwards compatibility
16211632
error = HTTPException

‎Lib/test/test_httplib.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io
55
import itertools
66
import os
7+
import pickle
78
import array
89
import re
910
import socket
@@ -2786,6 +2787,36 @@ def test_no_proxy_response_headers(self):
27862787
headers = self.conn.get_proxy_response_headers()
27872788
self.assertIsNone(headers)
27882789

2790+
def test_tunnel_error(self):
2791+
response_text = (
2792+
'HTTP/1.1 407 Proxy Authentication Required\r\n'
2793+
'Proxy-Authenticate: Basic realm="proxy"\r\n\r\n'
2794+
)
2795+
self.conn._create_connection = self._create_connection(response_text)
2796+
self.conn.set_tunnel('destination.com')
2797+
2798+
with self.assertRaises(client.ProxyTunnelError) as cm:
2799+
self.conn.request('HEAD', '/', '')
2800+
exc = cm.exception
2801+
self.assertIsInstance(exc, OSError)
2802+
self.assertEqual(exc.code, 407)
2803+
self.assertEqual(exc.reason, 'Proxy Authentication Required')
2804+
self.assertEqual(exc.headers['Proxy-Authenticate'],
2805+
'Basic realm="proxy"')
2806+
self.assertEqual(str(exc), 'Tunnel connection failed: '
2807+
'407 Proxy Authentication Required')
2808+
self.assertIsNone(self.conn.sock)
2809+
2810+
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
2811+
with self.subTest(proto=proto):
2812+
copy = pickle.loads(pickle.dumps(exc, proto))
2813+
self.assertIs(type(copy), client.ProxyTunnelError)
2814+
self.assertEqual(copy.code, 407)
2815+
self.assertEqual(copy.reason, exc.reason)
2816+
self.assertEqual(copy.headers['Proxy-Authenticate'],
2817+
'Basic realm="proxy"')
2818+
self.assertEqual(str(copy), str(exc))
2819+
27892820
def test_tunnel_leak(self):
27902821
sock = None
27912822

0 commit comments

Comments
 (0)