Skip to content

Commit 48fc2d4

Browse files
authored
Merge pull request #3633 from bdarnell/curl-reset-65
curl_httpclient: Reset the curl object before putting it on the freelist
2 parents 7d869c0 + 4ae1ddd commit 48fc2d4

5 files changed

Lines changed: 127 additions & 14 deletions

File tree

docs/releases.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Release notes
44
.. toctree::
55
:maxdepth: 2
66

7+
releases/v6.5.7
78
releases/v6.5.6
89
releases/v6.5.5
910
releases/v6.5.4

docs/releases/v6.5.7.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
What's new in Tornado 6.5.7
2+
===========================
3+
4+
Jun 8, 2026
5+
-----------
6+
7+
Security fixes
8+
~~~~~~~~~~~~~~
9+
10+
- ``CurlAsyncHTTPClient`` now fully resets the curl object before reusing it. This prevents
11+
incorrectly reusing options from a previous request, specifically including client SSL and
12+
credentials used for accessing proxies. Thanks to `Koh Jun Sheng <https://github.com/seankohjs>`_
13+
for reporting this issue.

tornado/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
# is zero for an official release, positive for a development branch,
2323
# or negative for a release candidate or beta (after the base version
2424
# number has been incremented)
25-
version = "6.5.6"
26-
version_info = (6, 5, 6, 0)
25+
version = "6.5.7"
26+
version_info = (6, 5, 7, 0)
2727

2828
import importlib
2929
import typing

tornado/curl_httpclient.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ def _process_queue(self) -> None:
225225
# _process_queue() is called from
226226
# _finish_pending_requests the exceptions have
227227
# nowhere to go.
228+
curl.reset()
228229
self._free_list.append(curl)
229230
callback(HTTPResponse(request=request, code=599, error=e))
230231
else:
@@ -242,7 +243,6 @@ def _finish(
242243
info = curl.info # type: ignore
243244
curl.info = None # type: ignore
244245
self._multi.remove_handle(curl)
245-
self._free_list.append(curl)
246246
buffer = info["buffer"]
247247
if curl_error:
248248
assert curl_message is not None
@@ -286,12 +286,22 @@ def _finish(
286286
)
287287
except Exception:
288288
self.handle_callback_exception(info["callback"])
289+
curl.reset()
290+
self._free_list.append(curl)
289291

290292
def handle_callback_exception(self, callback: Any) -> None:
291293
app_log.error("Exception in callback %r", callback, exc_info=True)
292294

293295
def _curl_create(self) -> pycurl.Curl:
294-
curl = pycurl.Curl()
296+
return pycurl.Curl()
297+
298+
def _curl_setup_request(
299+
self,
300+
curl: pycurl.Curl,
301+
request: HTTPRequest,
302+
buffer: BytesIO,
303+
headers: httputil.HTTPHeaders,
304+
) -> None:
295305
if curl_log.isEnabledFor(logging.DEBUG):
296306
curl.setopt(pycurl.VERBOSE, 1)
297307
curl.setopt(pycurl.DEBUGFUNCTION, self._curl_debug)
@@ -300,15 +310,7 @@ def _curl_create(self) -> pycurl.Curl:
300310
): # PROTOCOLS first appeared in pycurl 7.19.5 (2014-07-12)
301311
curl.setopt(pycurl.PROTOCOLS, pycurl.PROTO_HTTP | pycurl.PROTO_HTTPS)
302312
curl.setopt(pycurl.REDIR_PROTOCOLS, pycurl.PROTO_HTTP | pycurl.PROTO_HTTPS)
303-
return curl
304313

305-
def _curl_setup_request(
306-
self,
307-
curl: pycurl.Curl,
308-
request: HTTPRequest,
309-
buffer: BytesIO,
310-
headers: httputil.HTTPHeaders,
311-
) -> None:
312314
curl.setopt(pycurl.URL, native_str(request.url))
313315

314316
# libcurl's magic "Expect: 100-continue" behavior causes delays

tornado/test/curl_httpclient_test.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from hashlib import md5
2+
import os
3+
import ssl
24
import unittest
35

46
from tornado.escape import utf8
5-
from tornado.testing import AsyncHTTPTestCase
7+
from tornado.netutil import ssl_options_to_context
68
from tornado.test import httpclient_test
9+
from tornado.testing import AsyncHTTPSTestCase, AsyncHTTPTestCase
710
from tornado.web import Application, RequestHandler
811

9-
1012
try:
1113
import pycurl
1214
except ImportError:
@@ -123,3 +125,98 @@ def test_digest_auth_non_ascii(self):
123125
auth_password="barユ£",
124126
)
125127
self.assertEqual(response.body, b"ok")
128+
129+
130+
class ProxyAuthEchoHandler(RequestHandler):
131+
def get(self):
132+
if self.request.headers.get("Proxy-Authorization", None) is not None:
133+
self.write(f"proxy auth: {self.request.headers['Proxy-Authorization']}")
134+
else:
135+
self.write("no proxy auth")
136+
137+
138+
@unittest.skipIf(pycurl is None, "pycurl module not present")
139+
class CurlHTTPClientReuseProxyAuthTestCase(AsyncHTTPTestCase):
140+
def get_app(self):
141+
# Note that we don't properly support proxy-style requests, but it works well enough
142+
# for this test if we start the url matcher with a wildcard.
143+
return Application([(".*/proxy_auth", ProxyAuthEchoHandler)])
144+
145+
def get_http_client(self):
146+
# max_clients=1 forces us to reuse curl "easy handles". This is a regression test for
147+
# a bug in which proxy credentials were not cleared between requests.
148+
return CurlAsyncHTTPClient(
149+
force_instance=True,
150+
defaults=dict(
151+
allow_ipv6=False,
152+
),
153+
max_clients=1,
154+
)
155+
156+
def test_reuse_proxy_credentials(self):
157+
# Proxy credentials used on one request should not be automatically reused
158+
# by another request.
159+
response = self.fetch(
160+
"/proxy_auth",
161+
proxy_host="127.0.0.1",
162+
proxy_port=self.get_http_port(),
163+
proxy_username="foo",
164+
proxy_password="bar",
165+
)
166+
self.assertEqual(response.body, b"proxy auth: Basic Zm9vOmJhcg==")
167+
response = self.fetch(
168+
"/proxy_auth",
169+
proxy_host="127.0.0.1",
170+
proxy_port=self.get_http_port(),
171+
)
172+
self.assertEqual(response.body, b"no proxy auth")
173+
174+
175+
class ClientCertEchoHandler(RequestHandler):
176+
def get(self):
177+
cert = self.request.get_ssl_certificate()
178+
if cert is not None:
179+
assert isinstance(cert, dict)
180+
self.write(f"client cert: {cert['subject']}")
181+
else:
182+
self.write("no client cert")
183+
184+
185+
@unittest.skipIf(pycurl is None, "pycurl module not present")
186+
class CurlHTTPClientReuseCertsTestCase(AsyncHTTPSTestCase):
187+
def get_app(self):
188+
return Application([(".*/client_cert", ClientCertEchoHandler)])
189+
190+
def get_http_client(self):
191+
return CurlAsyncHTTPClient(
192+
force_instance=True,
193+
defaults=dict(
194+
allow_ipv6=False,
195+
validate_cert=False,
196+
),
197+
max_clients=1,
198+
)
199+
200+
def get_httpserver_options(self):
201+
ssl_ctx = ssl_options_to_context(self.get_ssl_options(), server_side=True)
202+
ssl_ctx.verify_mode = ssl.CERT_OPTIONAL
203+
return dict(ssl_options=ssl_ctx)
204+
205+
def get_ssl_options(self):
206+
opts = super().get_ssl_options()
207+
opts["ca_certs"] = os.path.join(os.path.dirname(__file__), "test.crt")
208+
return opts
209+
210+
def test_reuse_certs(self):
211+
# Client certs used on one request should not be automatically reused
212+
# by another request.
213+
response = self.fetch(
214+
self.get_url("/client_cert"),
215+
client_cert=os.path.join(os.path.dirname(__file__), "test.crt"),
216+
client_key=os.path.join(os.path.dirname(__file__), "test.key"),
217+
)
218+
self.assertEqual(
219+
response.body, b"client cert: ((('commonName', 'foo.example.com'),),)"
220+
)
221+
response = self.fetch(self.get_url("/client_cert"))
222+
self.assertEqual(response.body, b"no client cert")

0 commit comments

Comments
 (0)