Skip to content

Commit a55abe3

Browse files
authored
Merge pull request #3704 from bdarnell/security-6.5.8
Security 6.5.8
2 parents 48fc2d4 + fc79488 commit a55abe3

11 files changed

Lines changed: 177 additions & 35 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.8
78
releases/v6.5.7
89
releases/v6.5.6
910
releases/v6.5.5

docs/releases/v6.5.8.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
What's new in Tornado 6.5.8
2+
===========================
3+
4+
Aug 6, 2026
5+
-----------
6+
7+
Security fixes
8+
~~~~~~~~~~~~~~
9+
10+
- Form-encoded ``POST`` bodies are now subject to a limit of 1000 arguments by default. This
11+
prevents a CPU and memory denial of service attack. This limit can be overridden via the
12+
`.set_parse_body_config` function. Thanks to `Arpit Jain <https://github.com/arpitjain099>`_
13+
for reporting this issue.
14+
- Multipart parsing now rejects requests with an excessive number of parts earlier in the parsing
15+
process, limiting memory consumption. Thanks to `afldl <https://github.com/afldl>`_ for
16+
reporting this issue.
17+
- The deprecated mixed-case arguments to `.RequestHandler.set_cookie` now enforce the same
18+
restrictions on invalid characters that were introduced in Tornado 6.5.5 for the standard
19+
lowercase arguments. Thanks to `sec-reex <https://github.com/sec-reex>`_ and
20+
`Arpit Jain <https://github.com/arpitjain099>`_ for reporting this issue.
21+
22+
Deprecations
23+
~~~~~~~~~~~~
24+
25+
- The `.OpenIdMixin` class is deprecated and will be removed in Tornado 6.7. OpenID 2.0 is no
26+
longer widely supported by identity providers.

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

2828
import importlib
2929
import typing

tornado/auth.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,19 @@ class OpenIdMixin:
9898
Class attributes:
9999
100100
* ``_OPENID_ENDPOINT``: the identity provider's URI.
101+
102+
.. deprecated:: 6.6
103+
OpenID 2.0 is no longer widely supported by identity providers.
104+
This class will be removed in Tornado 6.7.
101105
"""
102106

107+
def __init__(self) -> None:
108+
warnings.warn(
109+
"OpenIdMixin is deprecated and will be removed in Tornado 6.7",
110+
DeprecationWarning,
111+
stacklevel=2,
112+
)
113+
103114
def authenticate_redirect(
104115
self,
105116
callback_uri: Optional[str] = None,

tornado/escape.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,21 +171,33 @@ def url_unescape(
171171

172172

173173
def parse_qs_bytes(
174-
qs: Union[str, bytes], keep_blank_values: bool = False, strict_parsing: bool = False
174+
qs: Union[str, bytes],
175+
keep_blank_values: bool = False,
176+
strict_parsing: bool = False,
177+
*,
178+
max_num_fields: Optional[int] = None,
175179
) -> Dict[str, List[bytes]]:
176180
"""Parses a query string like urlparse.parse_qs,
177181
but takes bytes and returns the values as byte strings.
178182
179183
Keys still become type str (interpreted as latin1 in python3!)
180184
because it's too painful to keep them as byte strings in
181185
python3 and in practice they're nearly always ascii anyway.
186+
187+
.. versionadded:: 6.5.8
188+
The ``max_num_fields`` argument. ValueError is raised if this limit is exceeded.
182189
"""
183190
# This is gross, but python3 doesn't give us another way.
184191
# Latin1 is the universal donor of character encodings.
185192
if isinstance(qs, bytes):
186193
qs = qs.decode("latin1")
187194
result = urllib.parse.parse_qs(
188-
qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict"
195+
qs,
196+
keep_blank_values,
197+
strict_parsing,
198+
encoding="latin1",
199+
errors="strict",
200+
max_num_fields=max_num_fields,
189201
)
190202
encoded = {}
191203
for k, v in result.items():

tornado/httputil.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
from tornado.escape import native_str, parse_qs_bytes, utf8, to_unicode
3838
from tornado.util import ObjectDict, unicode_type
3939

40-
4140
# responses is unused in this file, but we re-export it to other files.
4241
# Reference it so pyflakes doesn't complain.
4342
responses
@@ -948,6 +947,23 @@ class ParseMultipartConfig:
948947
"""
949948

950949

950+
@dataclasses.dataclass
951+
class ParseUrlEncodedConfig:
952+
"""This class configures the parsing of ``application/x-www-form-urlencoded`` request bodies.
953+
954+
Its primary purpose is to place limits on the size and complexity of request messages
955+
to avoid potential denial-of-service attacks.
956+
957+
.. versionadded:: 6.5.8
958+
"""
959+
960+
max_arguments: int = 1000
961+
"""The maximum number of arguments accepted in a urlencoded request.
962+
963+
Each ``<input>`` element in an HTML form corresponds to at least one argument.
964+
"""
965+
966+
951967
@dataclasses.dataclass
952968
class ParseBodyConfig:
953969
"""This class configures the parsing of request bodies.
@@ -958,6 +974,9 @@ class ParseBodyConfig:
958974
multipart: ParseMultipartConfig = dataclasses.field(
959975
default_factory=ParseMultipartConfig
960976
)
977+
urlencoded: ParseUrlEncodedConfig = dataclasses.field(
978+
default_factory=ParseUrlEncodedConfig
979+
)
961980
"""Configuration for ``multipart/form-data`` request bodies."""
962981

963982

@@ -1016,7 +1035,11 @@ def parse_body_arguments(
10161035
)
10171036
try:
10181037
# real charset decoding will happen in RequestHandler.decode_argument()
1019-
uri_arguments = parse_qs_bytes(body, keep_blank_values=True)
1038+
uri_arguments = parse_qs_bytes(
1039+
body,
1040+
keep_blank_values=True,
1041+
max_num_fields=config.urlencoded.max_arguments,
1042+
)
10201043
except Exception as e:
10211044
raise HTTPInputError("Invalid x-www-form-urlencoded body: %s" % e) from e
10221045
for name, values in uri_arguments.items():
@@ -1078,7 +1101,9 @@ def parse_multipart_form_data(
10781101
final_boundary_index = data.rfind(b"--" + boundary + b"--")
10791102
if final_boundary_index == -1:
10801103
raise HTTPInputError("Invalid multipart/form-data: no final boundary found")
1081-
parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n")
1104+
parts = data[:final_boundary_index].split(
1105+
b"--" + boundary + b"\r\n", config.max_parts + 1
1106+
)
10821107
if len(parts) > config.max_parts:
10831108
raise HTTPInputError("multipart/form-data has too many parts")
10841109
for part in parts:

tornado/test/auth_test.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from tornado.httpclient import HTTPClientError
1919
from tornado.httputil import url_concat
2020
from tornado.log import app_log
21-
from tornado.testing import AsyncHTTPTestCase, ExpectLog
21+
from tornado.testing import AsyncHTTPTestCase, ExpectLog, setup_with_context_manager
22+
from tornado.test.util import ignore_deprecation
2223
from tornado.web import RequestHandler, Application, HTTPError
2324

2425
try:
@@ -279,12 +280,46 @@ def get(self):
279280
self.write(dict(screen_name="foo", name="Foo"))
280281

281282

283+
class OpenIDAuthTest(AsyncHTTPTestCase):
284+
def setUp(self):
285+
setup_with_context_manager(self, ignore_deprecation())
286+
return super().setUp()
287+
288+
def get_app(self):
289+
return Application(
290+
[
291+
("/openid/client/login", OpenIdClientLoginHandler, dict(test=self)),
292+
("/openid/server/authenticate", OpenIdServerAuthenticateHandler),
293+
],
294+
http_client=self.http_client,
295+
)
296+
297+
def test_openid_redirect(self):
298+
with ignore_deprecation():
299+
response = self.fetch("/openid/client/login", follow_redirects=False)
300+
self.assertEqual(response.code, 302)
301+
self.assertIn("/openid/server/authenticate?", response.headers["Location"])
302+
303+
def test_openid_get_user(self):
304+
for i in range(2):
305+
with self.subTest(i=i):
306+
with ignore_deprecation():
307+
response = self.fetch(
308+
"/openid/client/login?openid.mode=blah"
309+
"&openid.ns.ax=http://openid.net/srv/ax/1.0"
310+
"&openid.ax.type.email=http://axschema.org/contact/email"
311+
"&openid.ax.value.email=foo@example.com"
312+
)
313+
response.rethrow()
314+
parsed = json_decode(response.body)
315+
self.assertEqual(parsed["email"], "foo@example.com")
316+
317+
282318
class AuthTest(AsyncHTTPTestCase):
283319
def get_app(self):
284320
return Application(
285321
[
286322
# test endpoints
287-
("/openid/client/login", OpenIdClientLoginHandler, dict(test=self)),
288323
(
289324
"/oauth10/client/login",
290325
OAuth1ClientLoginHandler,
@@ -329,7 +364,6 @@ def get_app(self):
329364
dict(test=self),
330365
),
331366
# simulated servers
332-
("/openid/server/authenticate", OpenIdServerAuthenticateHandler),
333367
("/oauth1/server/request_token", OAuth1ServerRequestTokenHandler),
334368
("/oauth1/server/access_token", OAuth1ServerAccessTokenHandler),
335369
("/facebook/server/access_token", FacebookServerAccessTokenHandler),
@@ -348,24 +382,6 @@ def get_app(self):
348382
facebook_secret="test_facebook_secret",
349383
)
350384

351-
def test_openid_redirect(self):
352-
response = self.fetch("/openid/client/login", follow_redirects=False)
353-
self.assertEqual(response.code, 302)
354-
self.assertIn("/openid/server/authenticate?", response.headers["Location"])
355-
356-
def test_openid_get_user(self):
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")
368-
369385
def test_oauth10_redirect(self):
370386
response = self.fetch("/oauth10/client/login", follow_redirects=False)
371387
self.assertEqual(response.code, 302)

tornado/test/httpclient_test.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,12 @@ def test_strip_headers_on_redirect(self):
795795
"/redirect?url=%s&status=302" % self.get_url2("/echo_headers")
796796
)
797797
if url_creds:
798-
url = url.replace("http://", "http://%s@" % url_creds)
798+
# Only add credentials to the outer URL being fetched, not to the
799+
# "url" query parameter (the redirect target), which also starts
800+
# with "http://". Otherwise the redirect's Location header would
801+
# carry its own explicit credentials for the new origin, which
802+
# libcurl legitimately honors instead of stripping.
803+
url = url.replace("http://", "http://%s@" % url_creds, 1)
799804
response = self.fetch(**dict(path=url) | kwargs)
800805
response.rethrow()
801806
echoed_headers = json_decode(response.body)
@@ -809,17 +814,27 @@ def test_strip_headers_on_redirect(self):
809814
"/redirect?url=%s&status=302" % self.get_url("/echo_headers")
810815
)
811816
if url_creds:
812-
url = url.replace("http://", "http://%s@" % url_creds)
817+
url = url.replace("http://", "http://%s@" % url_creds, 1)
813818
response = self.fetch(**dict(path=url) | kwargs)
814819
response.rethrow()
815820
echoed_headers = json_decode(response.body)
816821
# Confirm that non-auth headers are getting through
817822
self.assertIn("User-Agent", echoed_headers)
818-
# Auth headers are not stripped when the redirect is same-origin.
819-
# Each of our tests uses one of these headers, but not both.
820-
self.assertTrue(
821-
"Authorization" in echoed_headers or "Cookie" in echoed_headers
822-
)
823+
if name == "credentials in URL":
824+
# Some libcurl versions (known regression as of 8.20/8.21,
825+
# still present as of curl's git master) drop credentials
826+
# embedded in the URL across a same-origin redirect whose
827+
# Location header is an absolute URL, even though they
828+
# should be preserved. This isn't a security concern
829+
# (nothing is leaked to another origin), so just don't
830+
# assert on it either way here.
831+
pass
832+
else:
833+
# Auth headers are not stripped when the redirect is same-origin.
834+
# Each of our tests uses one of these headers, but not both.
835+
self.assertTrue(
836+
"Authorization" in echoed_headers or "Cookie" in echoed_headers
837+
)
823838

824839

825840
class RequestProxyTest(unittest.TestCase):

tornado/test/httputil_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from tornado.httputil import (
2+
parse_body_arguments,
23
url_concat,
34
parse_multipart_form_data,
45
HTTPHeaders,
@@ -95,6 +96,23 @@ def test_parsing(self):
9596
self.assertIn(("b", "2"), qsl)
9697

9798

99+
class UrlEncodedDataTest(unittest.TestCase):
100+
def test_urlencoded_data(self):
101+
data = b"a=1&b=2&a=3"
102+
args, files = form_data_args()
103+
parse_body_arguments("application/x-www-form-urlencoded", data, args, files)
104+
self.assertEqual(args["a"], [b"1", b"3"])
105+
self.assertEqual(args["b"], [b"2"])
106+
self.assertEqual(files, {})
107+
108+
def test_max_arguments(self):
109+
data = b"".join(b"a=1&" for _ in range(1001))
110+
args, files = form_data_args()
111+
with self.assertRaises(HTTPInputError) as cm:
112+
parse_body_arguments("application/x-www-form-urlencoded", data, args, files)
113+
self.assertIn("Max number of fields exceeded", str(cm.exception))
114+
115+
98116
class MultipartFormDataTest(unittest.TestCase):
99117
def test_file_upload(self):
100118
data = b"""\

tornado/test/web_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,18 @@ def get(self):
329329
"unexpected exception for char %r in domain: %s\n"
330330
% (char, e)
331331
)
332+
try:
333+
self.set_cookie("foo", "bar", DoMaIn="example" + char + ".com")
334+
self.write(
335+
"Didn't get expected exception for char %r in DoMaIn\n"
336+
% char
337+
)
338+
except http.cookies.CookieError as e:
339+
if "Invalid cookie attribute DoMaIn" not in str(e):
340+
self.write(
341+
"unexpected exception for char %r in DoMaIn: %s\n"
342+
% (char, e)
343+
)
332344

333345
try:
334346
self.set_cookie("foo", "bar", path="/" + char)

0 commit comments

Comments
 (0)