Skip to content

Commit c29e5fb

Browse files
bmbouterjborean93webknjaz
authored
Add secure proxy support in the client
This patch opens up the code path and adds the implementation that allows end-users to start sending HTTPS requests through HTTPS proxies. The support for TLS-in-TLS (needed for this to work) in the stdlib is kinda available since Python 3.7 but is disabled for `asyncio` with an attribute/flag/toggle. When the upstream CPython enables it finally, aiohttp v3.8+ will be able to work with it out of the box. Currently the tests monkey-patch `asyncio` in order to verify that this works. The users who are willing to do the same, will be able to take advantage of it right now. Eventually (hopefully starting Python 3.11), the need for monkey-patching should be eliminated. Refs: * https://bugs.python.org/issue37179 * python/cpython#28073 * https://docs.aiohttp.org/en/stable/client_advanced.html#proxy-support * aio-libs#6044 PR aio-libs#5992 Resolves aio-libs#3816 Resolves aio-libs#4268 Co-authored-by: Brian Bouterse <[email protected]> Co-authored-by: Jordan Borean <[email protected]> Co-authored-by: Sviatoslav Sydorenko <[email protected]>
1 parent 13c26be commit c29e5fb

File tree

8 files changed

+234
-129
lines changed

8 files changed

+234
-129
lines changed

CHANGES/5992.feature

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added support for HTTPS proxies to the extent CPython's
2+
:py:mod:`asyncio` supports it -- by :user:`bmbouter`,
3+
:user:`jborean93` and :user:`webknjaz`.

CONTRIBUTORS.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Boris Feld
5757
Borys Vorona
5858
Boyi Chen
5959
Brett Cannon
60+
Brian Bouterse
6061
Brian C. Lane
6162
Brian Muller
6263
Bruce Merry
@@ -165,6 +166,7 @@ Jonas Obrist
165166
Jonathan Wright
166167
Jonny Tan
167168
Joongi Kim
169+
Jordan Borean
168170
Josep Cugat
169171
Josh Junon
170172
Joshu Coats

aiohttp/client_reqrep.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,8 +481,6 @@ def update_proxy(
481481
proxy_auth: Optional[BasicAuth],
482482
proxy_headers: Optional[LooseHeaders],
483483
) -> None:
484-
if proxy and not proxy.scheme == "http":
485-
raise ValueError("Only http proxies are supported")
486484
if proxy_auth and not isinstance(proxy_auth, helpers.BasicAuth):
487485
raise ValueError("proxy_auth must be None or BasicAuth() tuple")
488486
self.proxy = proxy

aiohttp/connector.py

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,100 @@ async def _wrap_create_connection(
951951
except OSError as exc:
952952
raise client_error(req.connection_key, exc) from exc
953953

954+
def _warn_about_tls_in_tls(
955+
self,
956+
underlying_transport: asyncio.Transport,
957+
req: "ClientRequest",
958+
) -> None:
959+
"""Issue a warning if the requested URL has HTTPS scheme."""
960+
if req.request_info.url.scheme != "https":
961+
return
962+
963+
asyncio_supports_tls_in_tls = getattr(
964+
underlying_transport,
965+
"_start_tls_compatible",
966+
False,
967+
)
968+
969+
if asyncio_supports_tls_in_tls:
970+
return
971+
972+
warnings.warn(
973+
"An HTTPS request is being sent through an HTTPS proxy. "
974+
"This support for TLS in TLS is known to be disabled "
975+
"in the stdlib asyncio. This is why you'll probably see "
976+
"an error in the log below.\n\n"
977+
"It is possible to enable it via monkeypatching under "
978+
"Python 3.7 or higher. For more details, see:\n"
979+
"* https://bugs.python.org/issue37179\n"
980+
"* https://github.com/python/cpython/pull/28073\n\n"
981+
"You can temporarily patch this as follows:\n"
982+
"* https://docs.aiohttp.org/en/stable/client_advanced.html#proxy-support\n"
983+
"* https://github.com/aio-libs/aiohttp/discussions/6044\n",
984+
RuntimeWarning,
985+
source=self,
986+
# Why `4`? At least 3 of the calls in the stack originate
987+
# from the methods in this class.
988+
stacklevel=3,
989+
)
990+
991+
async def _start_tls_connection(
992+
self,
993+
underlying_transport: asyncio.Transport,
994+
req: "ClientRequest",
995+
timeout: "ClientTimeout",
996+
client_error: Type[Exception] = ClientConnectorError,
997+
) -> Tuple[asyncio.BaseTransport, ResponseHandler]:
998+
"""Wrap the raw TCP transport with TLS."""
999+
tls_proto = self._factory() # Create a brand new proto for TLS
1000+
1001+
# Safety of the `cast()` call here is based on the fact that
1002+
# internally `_get_ssl_context()` only returns `None` when
1003+
# `req.is_ssl()` evaluates to `False` which is never gonna happen
1004+
# in this code path. Of course, it's rather fragile
1005+
# maintainability-wise but this is to be solved separately.
1006+
sslcontext = cast(ssl.SSLContext, self._get_ssl_context(req))
1007+
1008+
try:
1009+
async with ceil_timeout(timeout.sock_connect):
1010+
try:
1011+
tls_transport = await self._loop.start_tls(
1012+
underlying_transport,
1013+
tls_proto,
1014+
sslcontext,
1015+
server_hostname=req.host,
1016+
ssl_handshake_timeout=timeout.total,
1017+
)
1018+
except BaseException:
1019+
# We need to close the underlying transport since
1020+
# `start_tls()` probably failed before it had a
1021+
# chance to do this:
1022+
underlying_transport.close()
1023+
raise
1024+
except cert_errors as exc:
1025+
raise ClientConnectorCertificateError(req.connection_key, exc) from exc
1026+
except ssl_errors as exc:
1027+
raise ClientConnectorSSLError(req.connection_key, exc) from exc
1028+
except OSError as exc:
1029+
raise client_error(req.connection_key, exc) from exc
1030+
except TypeError as type_err:
1031+
# Example cause looks like this:
1032+
# TypeError: transport <asyncio.sslproto._SSLProtocolTransport
1033+
# object at 0x7f760615e460> is not supported by start_tls()
1034+
1035+
raise ClientConnectionError(
1036+
"Cannot initialize a TLS-in-TLS connection to host "
1037+
f"{req.host!s}:{req.port:d} through an underlying connection "
1038+
f"to an HTTPS proxy {req.proxy!s} ssl:{req.ssl or 'default'} "
1039+
f"[{type_err!s}]"
1040+
) from type_err
1041+
else:
1042+
tls_proto.connection_made(
1043+
tls_transport
1044+
) # Kick the state machine of the new TLS protocol
1045+
1046+
return tls_transport, tls_proto
1047+
9541048
async def _create_direct_connection(
9551049
self,
9561050
req: "ClientRequest",
@@ -1028,7 +1122,7 @@ def drop_exception(fut: "asyncio.Future[List[Dict[str, Any]]]") -> None:
10281122

10291123
async def _create_proxy_connection(
10301124
self, req: "ClientRequest", traces: List["Trace"], timeout: "ClientTimeout"
1031-
) -> Tuple[asyncio.Transport, ResponseHandler]:
1125+
) -> Tuple[asyncio.BaseTransport, ResponseHandler]:
10321126
headers = {} # type: Dict[str, str]
10331127
if req.proxy_headers is not None:
10341128
headers = req.proxy_headers # type: ignore[assignment]
@@ -1063,7 +1157,8 @@ async def _create_proxy_connection(
10631157
proxy_req.headers[hdrs.PROXY_AUTHORIZATION] = auth
10641158

10651159
if req.is_ssl():
1066-
sslcontext = self._get_ssl_context(req)
1160+
self._warn_about_tls_in_tls(transport, req)
1161+
10671162
# For HTTPS requests over HTTP proxy
10681163
# we must notify proxy to tunnel connection
10691164
# so we send CONNECT command:
@@ -1083,7 +1178,11 @@ async def _create_proxy_connection(
10831178
try:
10841179
protocol = conn._protocol
10851180
assert protocol is not None
1086-
protocol.set_response_params()
1181+
1182+
# read_until_eof=True will ensure the connection isn't closed
1183+
# once the response is received and processed allowing
1184+
# START_TLS to work on the connection below.
1185+
protocol.set_response_params(read_until_eof=True)
10871186
resp = await proxy_resp.start(conn)
10881187
except BaseException:
10891188
proxy_resp.close()
@@ -1104,21 +1203,19 @@ async def _create_proxy_connection(
11041203
message=message,
11051204
headers=resp.headers,
11061205
)
1107-
rawsock = transport.get_extra_info("socket", default=None)
1108-
if rawsock is None:
1109-
raise RuntimeError("Transport does not expose socket instance")
1110-
# Duplicate the socket, so now we can close proxy transport
1111-
rawsock = rawsock.dup()
1112-
finally:
1206+
except BaseException:
1207+
# It shouldn't be closed in `finally` because it's fed to
1208+
# `loop.start_tls()` and the docs say not to touch it after
1209+
# passing there.
11131210
transport.close()
1211+
raise
11141212

1115-
transport, proto = await self._wrap_create_connection(
1116-
self._factory,
1117-
timeout=timeout,
1118-
ssl=sslcontext,
1119-
sock=rawsock,
1120-
server_hostname=req.host,
1213+
return await self._start_tls_connection(
1214+
# Access the old transport for the last time before it's
1215+
# closed and forgotten forever:
1216+
transport,
11211217
req=req,
1218+
timeout=timeout,
11221219
)
11231220
finally:
11241221
proxy_resp.close()

docs/client_advanced.rst

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,11 @@ DER with e.g::
533533
Proxy support
534534
-------------
535535

536-
aiohttp supports plain HTTP proxies and HTTP proxies that can be upgraded to HTTPS
537-
via the HTTP CONNECT method. aiohttp does not support proxies that must be
538-
connected to via ``https://``. To connect, use the *proxy* parameter::
536+
aiohttp supports plain HTTP proxies and HTTP proxies that can be
537+
upgraded to HTTPS via the HTTP CONNECT method. aiohttp has a limited
538+
support for proxies that must be connected to via ``https://`` — see
539+
the info box below for more details.
540+
To connect, use the *proxy* parameter::
539541

540542
async with aiohttp.ClientSession() as session:
541543
async with session.get("http://python.org",
@@ -570,6 +572,33 @@ variables* (all are case insensitive)::
570572
Proxy credentials are given from ``~/.netrc`` file if present (see
571573
:class:`aiohttp.ClientSession` for more details).
572574

575+
.. attention::
576+
577+
CPython has introduced the support for TLS in TLS around Python 3.7.
578+
But, as of now (Python 3.10), it's disabled for the transports that
579+
:py:mod:`asyncio` uses. If the further release of Python (say v3.11)
580+
toggles one attribute, it'll *just work™*.
581+
582+
aiohttp v3.8 and higher is ready for this to happen and has code in
583+
place supports TLS-in-TLS, hence sending HTTPS requests over HTTPS
584+
proxy tunnels.
585+
586+
⚠️ For as long as your Python runtime doesn't declare the support for
587+
TLS-in-TLS, please don't file bugs with aiohttp but rather try to
588+
help the CPython upstream enable this feature. Meanwhile, if you
589+
*really* need this to work, there's a patch that may help you make
590+
it happen, include it into your app's code base:
591+
https://github.com/aio-libs/aiohttp/discussions/6044#discussioncomment-1432443.
592+
593+
.. important::
594+
595+
When supplying a custom :py:class:`ssl.SSLContext` instance, bear in
596+
mind that it will be used not only to establish a TLS session with
597+
the HTTPS endpoint you're hitting but also to establish a TLS tunnel
598+
to the HTTPS proxy. To avoid surprises, make sure to set up the trust
599+
chain that would recognize TLS certificates used by both the endpoint
600+
and the proxy.
601+
573602
.. _aiohttp-persistent-session:
574603

575604
Persistent session

tests/test_client_request.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,6 @@ def test_version_err(make_request: Any) -> None:
119119
make_request("get", "http://python.org/", version="1.c")
120120

121121

122-
def test_https_proxy(make_request: Any) -> None:
123-
with pytest.raises(ValueError):
124-
make_request("get", "http://python.org/", proxy=URL("https://proxy.org"))
125-
126-
127122
def test_keep_alive(make_request: Any) -> None:
128123
req = make_request("get", "http://python.org/", version=(0, 9))
129124
assert not req.keep_alive()

0 commit comments

Comments
 (0)