Skip to content

Commit aba2569

Browse files
authored
Merge pull request #3626 from bdarnell/fixes-656
Combined changes for release 6.5.6
2 parents 2761431 + a24b260 commit aba2569

13 files changed

Lines changed: 231 additions & 75 deletions

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.6
78
releases/v6.5.5
89
releases/v6.5.4
910
releases/v6.5.3

docs/releases/v6.5.6.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
What's new in Tornado 6.5.6
2+
===========================
3+
4+
May 27, 2026
5+
------------
6+
7+
Security fixes
8+
~~~~~~~~~~~~~~
9+
10+
- ``SimpleAsyncHTTPClient`` now strips the ``Authorization`` and ``Cookie`` headers from the request
11+
when following a redirect to a different origin. This matches the default behavior of
12+
``CurlAsyncHTTPClient``. Applications that need different behavior here can set
13+
``follow_redirects=False`` and handle redirects manually. Thanks to [Yannick
14+
Wang](https://github.com/noobone123) for being first to report this issue, as well as additional
15+
reporters [Kai Aizen](https://github.com/SnailSploit), [HunSec](https://github.com/0xHunSec), and
16+
[Thai Son Dinh](https://github.com/sondt99).
17+
- ``SimpleAsyncHTTPClient`` now enforces ``max_body_size`` on the decompressed size of the response,
18+
rather than the compressed size. This prevents a denial-of-service attack via a very large
19+
compressed response. Thanks to [Yuichiro Kedashiro](https://github.com/yuui25) for reporting this
20+
issue.
21+
- Fixed a bug in the C extension that could have read up to three bytes past the end of an input
22+
array. Thanks to [Thai Son Dinh](https://github.com/sondt99) for reporting this issue.
23+
- ``OpenIDMixin`` has improved parsing for the ``check_authentication`` response. Thanks to
24+
[Yannick Wang](https://github.com/noobone123) for reporting this issue.
25+
26+
Bug fixes
27+
~~~~~~~~~
28+
29+
- ``CurlAsyncHTTPClient`` has been updated to use non-deprecated APIs, avoiding deprecation
30+
warnings with recent versions of ``pycurl``.

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.5"
26-
version_info = (6, 5, 5, 0)
25+
version = "6.5.6"
26+
version_info = (6, 5, 6, 0)
2727

2828
import importlib
2929
import typing

tornado/auth.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ async def get(self):
7373
import binascii
7474
import hashlib
7575
import hmac
76+
import re
7677
import time
7778
import urllib.parse
7879
import uuid
@@ -217,7 +218,7 @@ def _on_authentication_verified(
217218
self, response: httpclient.HTTPResponse
218219
) -> Dict[str, Any]:
219220
handler = cast(RequestHandler, self)
220-
if b"is_valid:true" not in response.body:
221+
if re.search(rb"(?m)^is_valid:true$", response.body) is None:
221222
raise AuthError("Invalid OpenID response: %r" % response.body)
222223

223224
# Make sure we got back at least an email from attribute exchange

tornado/curl_httpclient.py

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,6 @@ def initialize( # type: ignore
6565
self._fds = {} # type: Dict[int, int]
6666
self._timeout = None # type: Optional[object]
6767

68-
# libcurl has bugs that sometimes cause it to not report all
69-
# relevant file descriptors and timeouts to TIMERFUNCTION/
70-
# SOCKETFUNCTION. Mitigate the effects of such bugs by
71-
# forcing a periodic scan of all active requests.
72-
self._force_timeout_callback = ioloop.PeriodicCallback(
73-
self._handle_force_timeout, 1000
74-
)
75-
self._force_timeout_callback.start()
76-
7768
# Work around a bug in libcurl 7.29.0: Some fields in the curl
7869
# multi object are initialized lazily, and its destructor will
7970
# segfault if it is destroyed without having been used. Add
@@ -84,7 +75,6 @@ def initialize( # type: ignore
8475
self._multi.remove_handle(dummy_curl_handle)
8576

8677
def close(self) -> None:
87-
self._force_timeout_callback.stop()
8878
if self._timeout is not None:
8979
self.io_loop.remove_timeout(self._timeout)
9080
for curl in self._curls:
@@ -95,7 +85,6 @@ def close(self) -> None:
9585
# Set below properties to None to reduce the reference count of current
9686
# instance, because those properties hold some methods of current
9787
# instance that will case circular reference.
98-
self._force_timeout_callback = None # type: ignore
9988
self._multi = None
10089

10190
def fetch_impl(
@@ -189,19 +178,6 @@ def _handle_timeout(self) -> None:
189178
if new_timeout >= 0:
190179
self._set_timeout(new_timeout)
191180

192-
def _handle_force_timeout(self) -> None:
193-
"""Called by IOLoop periodically to ask libcurl to process any
194-
events it may have forgotten about.
195-
"""
196-
while True:
197-
try:
198-
ret, num_handles = self._multi.socket_all()
199-
except pycurl.error as e:
200-
ret = e.args[0]
201-
if ret != pycurl.E_CALL_MULTI_PERFORM:
202-
break
203-
self._finish_pending_requests()
204-
205181
def _finish_pending_requests(self) -> None:
206182
"""Process any requests that were completed by the last
207183
call to multi.socket_action.
@@ -484,12 +460,12 @@ def write_function(b: Union[bytes, bytearray]) -> int:
484460
raise ValueError("Body must be None for GET request")
485461
request_buffer = BytesIO(utf8(request.body or ""))
486462

487-
def ioctl(cmd: int) -> None:
488-
if cmd == curl.IOCMD_RESTARTREAD: # type: ignore
489-
request_buffer.seek(0)
463+
def seek(offset: int, origin: int) -> int:
464+
request_buffer.seek(offset, origin)
465+
return pycurl.SEEKFUNC_OK
490466

491467
curl.setopt(pycurl.READFUNCTION, request_buffer.read)
492-
curl.setopt(pycurl.IOCTLFUNCTION, ioctl)
468+
curl.setopt(pycurl.SEEKFUNCTION, seek)
493469
if request.method == "POST":
494470
curl.setopt(pycurl.POSTFIELDSIZE, len(request.body or ""))
495471
else:

tornado/http1connection.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ def read_response(self, delegate: httputil.HTTPMessageDelegate) -> Awaitable[boo
182182
been read. The result is true if the stream is still open.
183183
"""
184184
if self.params.decompress:
185-
delegate = _GzipMessageDelegate(delegate, self.params.chunk_size)
185+
delegate = _GzipMessageDelegate(
186+
delegate, self.params.chunk_size, self._max_body_size
187+
)
186188
return self._read_message(delegate)
187189

188190
async def _read_message(self, delegate: httputil.HTTPMessageDelegate) -> bool:
@@ -705,9 +707,16 @@ async def _read_body_until_close(
705707
class _GzipMessageDelegate(httputil.HTTPMessageDelegate):
706708
"""Wraps an `HTTPMessageDelegate` to decode ``Content-Encoding: gzip``."""
707709

708-
def __init__(self, delegate: httputil.HTTPMessageDelegate, chunk_size: int) -> None:
710+
def __init__(
711+
self,
712+
delegate: httputil.HTTPMessageDelegate,
713+
chunk_size: int,
714+
max_body_size: int,
715+
) -> None:
709716
self._delegate = delegate
710717
self._chunk_size = chunk_size
718+
self._max_body_size = max_body_size
719+
self._decompressed_body_size = 0
711720
self._decompressor = None # type: Optional[GzipDecompressor]
712721

713722
def headers_received(
@@ -732,6 +741,9 @@ async def data_received(self, chunk: bytes) -> None:
732741
compressed_data, self._chunk_size
733742
)
734743
if decompressed:
744+
self._decompressed_body_size += len(decompressed)
745+
if self._decompressed_body_size > self._max_body_size:
746+
raise httputil.HTTPInputError("decompressed body too large")
735747
ret = self._delegate.data_received(decompressed)
736748
if ret is not None:
737749
await ret

tornado/simple_httpclient.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,42 @@ def finish(self) -> None:
631631
new_request.url = urllib.parse.urljoin(
632632
self.request.url, self.headers["Location"]
633633
)
634+
new_request.headers = self.request.headers.copy()
635+
parsed_orig_url = urllib.parse.urlsplit(original_request.url)
636+
parsed_new_url = urllib.parse.urlsplit(new_request.url)
637+
if (
638+
parsed_orig_url.scheme != parsed_new_url.scheme
639+
or parsed_orig_url.netloc != parsed_new_url.netloc
640+
):
641+
# Cross-origin redirect: strip auth headers.
642+
# Note that while there is no formal specification of headers that should be
643+
# stripped here, libcurl strips the Authorization and Cookie headers, so we
644+
# do the same.
645+
# Reference:
646+
# https://github.com/curl/curl/blob/01d8191b25a05e8fa91553a6c0d48acb99907d26/lib/http.c#L1827-L1828
647+
#
648+
# Note that checking for cross-origin redirects is a crude heuristic. It is both
649+
# too weak (e.g. cookies that have a path attribute may need to be stripped even on
650+
# same-origin redirects) and too strong (e.g. cookies may be kept on cross-host
651+
# redirects within the same domain). However, we cannot know the full details of
652+
# the cookie policy at this layer, so we use the same heuristic as libcurl.
653+
# Applications that need more control over behavior on redirects can set
654+
# follow_redirects=False and handle 3xx responses themselves.
655+
new_request.auth_username = None
656+
new_request.auth_password = None
657+
if "@" in parsed_new_url.netloc:
658+
if parsed_new_url.port is not None:
659+
new_netloc = f"{parsed_new_url.hostname}:{parsed_new_url.port}"
660+
else:
661+
assert parsed_new_url.hostname is not None
662+
new_netloc = parsed_new_url.hostname
663+
parsed_new_url = parsed_new_url._replace(netloc=new_netloc)
664+
new_request.url = urllib.parse.urlunsplit(parsed_new_url)
665+
for h in ["Authorization", "Cookie"]:
666+
try:
667+
del new_request.headers[h]
668+
except KeyError:
669+
pass
634670
assert self.request.max_redirects is not None
635671
new_request.max_redirects = self.request.max_redirects - 1
636672
del new_request.headers["Host"]
@@ -655,7 +691,7 @@ def finish(self) -> None:
655691
"Transfer-Encoding",
656692
]:
657693
try:
658-
del self.request.headers[h]
694+
del new_request.headers[h]
659695
except KeyError:
660696
pass
661697
new_request.original_request = original_request # type: ignore

tornado/speedups.c

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,63 +2,76 @@
22
#include <Python.h>
33
#include <stdint.h>
44

5-
static PyObject* websocket_mask(PyObject* self, PyObject* args) {
6-
const char* mask;
5+
static PyObject *websocket_mask(PyObject *self, PyObject *args)
6+
{
7+
const char *mask;
78
Py_ssize_t mask_len;
89
uint32_t uint32_mask;
910
uint64_t uint64_mask;
10-
const char* data;
11+
const char *data;
1112
Py_ssize_t data_len;
1213
Py_ssize_t i;
13-
PyObject* result;
14-
char* buf;
14+
PyObject *result;
15+
char *buf;
1516

16-
if (!PyArg_ParseTuple(args, "s#s#", &mask, &mask_len, &data, &data_len)) {
17+
if (!PyArg_ParseTuple(args, "s#s#", &mask, &mask_len, &data, &data_len))
18+
{
1719
return NULL;
1820
}
1921

20-
uint32_mask = ((uint32_t*)mask)[0];
22+
if (mask_len != 4)
23+
{
24+
PyErr_SetString(PyExc_ValueError, "mask must be 4 bytes");
25+
return NULL;
26+
}
27+
28+
uint32_mask = ((uint32_t *)mask)[0];
2129

2230
result = PyBytes_FromStringAndSize(NULL, data_len);
23-
if (!result) {
31+
if (!result)
32+
{
2433
return NULL;
2534
}
2635
buf = PyBytes_AsString(result);
2736

28-
if (sizeof(size_t) >= 8) {
37+
if (sizeof(size_t) >= 8)
38+
{
2939
uint64_mask = uint32_mask;
3040
uint64_mask = (uint64_mask << 32) | uint32_mask;
3141

32-
while (data_len >= 8) {
33-
((uint64_t*)buf)[0] = ((uint64_t*)data)[0] ^ uint64_mask;
42+
while (data_len >= 8)
43+
{
44+
((uint64_t *)buf)[0] = ((uint64_t *)data)[0] ^ uint64_mask;
3445
data += 8;
3546
buf += 8;
3647
data_len -= 8;
3748
}
3849
}
3950

40-
while (data_len >= 4) {
41-
((uint32_t*)buf)[0] = ((uint32_t*)data)[0] ^ uint32_mask;
51+
while (data_len >= 4)
52+
{
53+
((uint32_t *)buf)[0] = ((uint32_t *)data)[0] ^ uint32_mask;
4254
data += 4;
4355
buf += 4;
4456
data_len -= 4;
4557
}
4658

47-
for (i = 0; i < data_len; i++) {
59+
for (i = 0; i < data_len; i++)
60+
{
4861
buf[i] = data[i] ^ mask[i];
4962
}
5063

5164
return result;
5265
}
5366

54-
static int speedups_exec(PyObject *module) {
67+
static int speedups_exec(PyObject *module)
68+
{
5569
return 0;
5670
}
5771

5872
static PyMethodDef methods[] = {
59-
{"websocket_mask", websocket_mask, METH_VARARGS, ""},
60-
{NULL, NULL, 0, NULL}
61-
};
73+
{"websocket_mask", websocket_mask, METH_VARARGS, ""},
74+
{NULL, NULL, 0, NULL}};
6275

6376
static PyModuleDef_Slot slots[] = {
6477
{Py_mod_exec, speedups_exec},
@@ -68,19 +81,19 @@ static PyModuleDef_Slot slots[] = {
6881
#if (!defined(Py_LIMITED_API) && PY_VERSION_HEX >= 0x030d0000) || Py_LIMITED_API >= 0x030d0000
6982
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
7083
#endif
71-
{0, NULL}
72-
};
84+
{0, NULL}};
7385

7486
static struct PyModuleDef speedupsmodule = {
75-
PyModuleDef_HEAD_INIT,
76-
"speedups",
77-
NULL,
78-
0,
79-
methods,
80-
slots,
87+
PyModuleDef_HEAD_INIT,
88+
"speedups",
89+
NULL,
90+
0,
91+
methods,
92+
slots,
8193
};
8294

8395
PyMODINIT_FUNC
84-
PyInit_speedups(void) {
96+
PyInit_speedups(void)
97+
{
8598
return PyModuleDef_Init(&speedupsmodule);
8699
}

tornado/test/auth_test.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,20 @@ def get(self):
4646

4747

4848
class OpenIdServerAuthenticateHandler(RequestHandler):
49+
flip_flop = False
50+
4951
def post(self):
5052
if self.get_argument("openid.mode") != "check_authentication":
5153
raise Exception("incorrect openid.mode %r")
52-
self.write("is_valid:true")
54+
# Cover both orderings of the response parameters if we call this handler twice.
55+
# (the flip_flop side effect is simpler than plumbing parameters around).
56+
# We check both orderings to catch mistaken uses of re.match instead of re.search
57+
# or incorrect matching of the newline characters.
58+
if type(self).flip_flop:
59+
self.write("is_valid:true\nns:http://specs.openid.net/auth/2.0\n")
60+
else:
61+
self.write("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n")
62+
type(self).flip_flop = not type(self).flip_flop
5363

5464

5565
class OAuth1ClientLoginHandler(RequestHandler, OAuthMixin):
@@ -344,15 +354,17 @@ def test_openid_redirect(self):
344354
self.assertIn("/openid/server/authenticate?", response.headers["Location"])
345355

346356
def test_openid_get_user(self):
347-
response = self.fetch(
348-
"/openid/client/login?openid.mode=blah"
349-
"&openid.ns.ax=http://openid.net/srv/ax/1.0"
350-
"&openid.ax.type.email=http://axschema.org/contact/email"
351-
"&openid.ax.value.email=foo@example.com"
352-
)
353-
response.rethrow()
354-
parsed = json_decode(response.body)
355-
self.assertEqual(parsed["email"], "foo@example.com")
357+
for i in range(2):
358+
with self.subTest(i=i):
359+
response = self.fetch(
360+
"/openid/client/login?openid.mode=blah"
361+
"&openid.ns.ax=http://openid.net/srv/ax/1.0"
362+
"&openid.ax.type.email=http://axschema.org/contact/email"
363+
"&openid.ax.value.email=foo@example.com"
364+
)
365+
response.rethrow()
366+
parsed = json_decode(response.body)
367+
self.assertEqual(parsed["email"], "foo@example.com")
356368

357369
def test_oauth10_redirect(self):
358370
response = self.fetch("/oauth10/client/login", follow_redirects=False)

0 commit comments

Comments
 (0)