Compare commits

2 Commits
main ... 1.1

7 changed files with 400 additions and 50 deletions

View File

@@ -0,0 +1,138 @@
From d5ba4a1695fbf7c6a3e54313262639b198291533 Mon Sep 17 00:00:00 2001
From: Ben Darnell <ben@bendarnell.com>
Date: Thu, 21 Nov 2024 14:48:05 -0500
Subject: [PATCH] httputil: Fix quadratic performance of cookie parsing
Maliciously-crafted cookies can cause Tornado to
spend an unreasonable amount of CPU time and block
the event loop.
This change replaces the quadratic algorithm with
a more efficient one. The implementation is copied
from the Python 3.13 standard library (the
previous one was from Python 3.5).
Fixes CVE-2024-52804
See CVE-2024-7592 for a similar vulnerability in cpython.
Thanks to github.com/kexinoh for the report.
---
tornado/httputil.py | 38 ++++++++---------------------
tornado/test/httputil_test.py | 46 +++++++++++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 28 deletions(-)
diff --git a/tornado/httputil.py b/tornado/httputil.py
index 9ce992d82b..ebdc8059c1 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -1057,15 +1057,20 @@ def qs_to_qsl(qs: Dict[str, List[AnyStr]]) -> Iterable[Tuple[str, AnyStr]]:
yield (k, v)
-_OctalPatt = re.compile(r"\\[0-3][0-7][0-7]")
-_QuotePatt = re.compile(r"[\\].")
-_nulljoin = "".join
+_unquote_sub = re.compile(r"\\(?:([0-3][0-7][0-7])|(.))").sub
+
+
+def _unquote_replace(m: re.Match) -> str:
+ if m[1]:
+ return chr(int(m[1], 8))
+ else:
+ return m[2]
def _unquote_cookie(s: str) -> str:
"""Handle double quotes and escaping in cookie values.
- This method is copied verbatim from the Python 3.5 standard
+ This method is copied verbatim from the Python 3.13 standard
library (http.cookies._unquote) so we don't have to depend on
non-public interfaces.
"""
@@ -1086,30 +1091,7 @@ def _unquote_cookie(s: str) -> str:
# \012 --> \n
# \" --> "
#
- i = 0
- n = len(s)
- res = []
- while 0 <= i < n:
- o_match = _OctalPatt.search(s, i)
- q_match = _QuotePatt.search(s, i)
- if not o_match and not q_match: # Neither matched
- res.append(s[i:])
- break
- # else:
- j = k = -1
- if o_match:
- j = o_match.start(0)
- if q_match:
- k = q_match.start(0)
- if q_match and (not o_match or k < j): # QuotePatt matched
- res.append(s[i:k])
- res.append(s[k + 1])
- i = k + 2
- else: # OctalPatt matched
- res.append(s[i:j])
- res.append(chr(int(s[j + 1 : j + 4], 8)))
- i = j + 4
- return _nulljoin(res)
+ return _unquote_sub(_unquote_replace, s)
def parse_cookie(cookie: str) -> Dict[str, str]:
diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py
index 6d618839e0..975900aa9c 100644
--- a/tornado/test/httputil_test.py
+++ b/tornado/test/httputil_test.py
@@ -560,3 +560,49 @@ def test_invalid_cookies(self):
self.assertEqual(
parse_cookie(" = b ; ; = ; c = ; "), {"": "b", "c": ""}
)
+
+ def test_unquote(self):
+ # Copied from
+ # https://github.com/python/cpython/blob/dc7a2b6522ec7af41282bc34f405bee9b306d611/Lib/test/test_http_cookies.py#L62
+ cases = [
+ (r'a="b=\""', 'b="'),
+ (r'a="b=\\"', "b=\\"),
+ (r'a="b=\="', "b=="),
+ (r'a="b=\n"', "b=n"),
+ (r'a="b=\042"', 'b="'),
+ (r'a="b=\134"', "b=\\"),
+ (r'a="b=\377"', "b=\xff"),
+ (r'a="b=\400"', "b=400"),
+ (r'a="b=\42"', "b=42"),
+ (r'a="b=\\042"', "b=\\042"),
+ (r'a="b=\\134"', "b=\\134"),
+ (r'a="b=\\\""', 'b=\\"'),
+ (r'a="b=\\\042"', 'b=\\"'),
+ (r'a="b=\134\""', 'b=\\"'),
+ (r'a="b=\134\042"', 'b=\\"'),
+ ]
+ for encoded, decoded in cases:
+ with self.subTest(encoded):
+ c = parse_cookie(encoded)
+ self.assertEqual(c["a"], decoded)
+
+ def test_unquote_large(self):
+ # Adapted from
+ # https://github.com/python/cpython/blob/dc7a2b6522ec7af41282bc34f405bee9b306d611/Lib/test/test_http_cookies.py#L87
+ # Modified from that test because we handle semicolons differently from the stdlib.
+ #
+ # This is a performance regression test: prior to improvements in Tornado 6.4.2, this test
+ # would take over a minute with n= 100k. Now it runs in tens of milliseconds.
+ n = 100000
+ for encoded in r"\\", r"\134":
+ with self.subTest(encoded):
+ start = time.time()
+ data = 'a="b=' + encoded * n + '"'
+ value = parse_cookie(data)["a"]
+ end = time.time()
+ self.assertEqual(value[:3], "b=\\")
+ self.assertEqual(value[-3:], "\\\\\\")
+ self.assertEqual(len(value), n + 2)
+
+ # Very loose performance check to avoid false positives
+ self.assertLess(end - start, 1, "Test took too long")

228
CVE-2025-47287.patch Normal file
View File

@@ -0,0 +1,228 @@
From cc61050e8f26697463142d99864b562e8470b41d Mon Sep 17 00:00:00 2001
From: Ben Darnell <ben@bendarnell.com>
Date: Thu, 8 May 2025 13:29:43 -0400
Subject: [PATCH] httputil: Raise errors instead of logging in
multipart/form-data parsing
We used to continue after logging an error, which allowed repeated
errors to spam the logs. The error raised here will still be logged,
but only once per request, consistent with other error handling in
Tornado.
---
tornado/httputil.py | 30 +++++++++++-------------------
tornado/test/httpserver_test.py | 4 ++--
tornado/test/httputil_test.py | 13 ++++++++-----
tornado/web.py | 17 +++++++++++++----
4 files changed, 34 insertions(+), 30 deletions(-)
Index: tornado-6.4/tornado/httputil.py
===================================================================
--- tornado-6.4.orig/tornado/httputil.py
+++ tornado-6.4/tornado/httputil.py
@@ -34,7 +34,6 @@ import unicodedata
from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl
from tornado.escape import native_str, parse_qs_bytes, utf8
-from tornado.log import gen_log
from tornado.util import ObjectDict, unicode_type
@@ -759,25 +758,22 @@ def parse_body_arguments(
"""
if content_type.startswith("application/x-www-form-urlencoded"):
if headers and "Content-Encoding" in headers:
- gen_log.warning(
- "Unsupported Content-Encoding: %s", headers["Content-Encoding"]
+ raise HTTPInputError(
+ "Unsupported Content-Encoding: %s" % headers["Content-Encoding"]
)
- return
try:
# real charset decoding will happen in RequestHandler.decode_argument()
uri_arguments = parse_qs_bytes(body, keep_blank_values=True)
except Exception as e:
- gen_log.warning("Invalid x-www-form-urlencoded body: %s", e)
- uri_arguments = {}
+ raise HTTPInputError("Invalid x-www-form-urlencoded body: %s" % e) from e
for name, values in uri_arguments.items():
if values:
arguments.setdefault(name, []).extend(values)
elif content_type.startswith("multipart/form-data"):
if headers and "Content-Encoding" in headers:
- gen_log.warning(
- "Unsupported Content-Encoding: %s", headers["Content-Encoding"]
+ raise HTTPInputError(
+ "Unsupported Content-Encoding: %s" % headers["Content-Encoding"]
)
- return
try:
fields = content_type.split(";")
for field in fields:
@@ -786,9 +782,9 @@ def parse_body_arguments(
parse_multipart_form_data(utf8(v), body, arguments, files)
break
else:
- raise ValueError("multipart boundary not found")
+ raise HTTPInputError("multipart boundary not found")
except Exception as e:
- gen_log.warning("Invalid multipart/form-data: %s", e)
+ raise HTTPInputError("Invalid multipart/form-data: %s" % e) from e
def parse_multipart_form_data(
@@ -817,26 +813,22 @@ def parse_multipart_form_data(
boundary = boundary[1:-1]
final_boundary_index = data.rfind(b"--" + boundary + b"--")
if final_boundary_index == -1:
- gen_log.warning("Invalid multipart/form-data: no final boundary")
- return
+ raise HTTPInputError("Invalid multipart/form-data: no final boundary found")
parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n")
for part in parts:
if not part:
continue
eoh = part.find(b"\r\n\r\n")
if eoh == -1:
- gen_log.warning("multipart/form-data missing headers")
- continue
+ raise HTTPInputError("multipart/form-data missing headers")
headers = HTTPHeaders.parse(part[:eoh].decode("utf-8"))
disp_header = headers.get("Content-Disposition", "")
disposition, disp_params = _parse_header(disp_header)
if disposition != "form-data" or not part.endswith(b"\r\n"):
- gen_log.warning("Invalid multipart/form-data")
- continue
+ raise HTTPInputError("Invalid multipart/form-data")
value = part[eoh + 4 : -2]
if not disp_params.get("name"):
- gen_log.warning("multipart/form-data value missing name")
- continue
+ raise HTTPInputError("multipart/form-data missing name")
name = disp_params["name"]
if disp_params.get("filename"):
ctype = headers.get("Content-Type", "application/unknown")
Index: tornado-6.4/tornado/test/httpserver_test.py
===================================================================
--- tornado-6.4.orig/tornado/test/httpserver_test.py
+++ tornado-6.4/tornado/test/httpserver_test.py
@@ -1061,9 +1061,9 @@ class GzipUnsupportedTest(GzipBaseTest,
# Gzip support is opt-in; without it the server fails to parse
# the body (but parsing form bodies is currently just a log message,
# not a fatal error).
- with ExpectLog(gen_log, "Unsupported Content-Encoding"):
+ with ExpectLog(gen_log, ".*Unsupported Content-Encoding"):
response = self.post_gzip("foo=bar")
- self.assertEqual(json_decode(response.body), {})
+ self.assertEqual(response.code, 400)
class StreamingChunkSizeTest(AsyncHTTPTestCase):
Index: tornado-6.4/tornado/test/httputil_test.py
===================================================================
--- tornado-6.4.orig/tornado/test/httputil_test.py
+++ tornado-6.4/tornado/test/httputil_test.py
@@ -12,7 +12,6 @@ from tornado.httputil import (
)
from tornado.escape import utf8, native_str
from tornado.log import gen_log
-from tornado.testing import ExpectLog
from tornado.test.util import ignore_deprecation
import copy
@@ -195,7 +194,9 @@ Foo
b"\n", b"\r\n"
)
args, files = form_data_args()
- with ExpectLog(gen_log, "multipart/form-data missing headers"):
+ with self.assertRaises(
+ HTTPInputError, msg="multipart/form-data missing headers"
+ ):
parse_multipart_form_data(b"1234", data, args, files)
self.assertEqual(files, {})
@@ -209,7 +210,7 @@ Foo
b"\n", b"\r\n"
)
args, files = form_data_args()
- with ExpectLog(gen_log, "Invalid multipart/form-data"):
+ with self.assertRaises(HTTPInputError, msg="Invalid multipart/form-data"):
parse_multipart_form_data(b"1234", data, args, files)
self.assertEqual(files, {})
@@ -222,7 +223,7 @@ Foo--1234--""".replace(
b"\n", b"\r\n"
)
args, files = form_data_args()
- with ExpectLog(gen_log, "Invalid multipart/form-data"):
+ with self.assertRaises(HTTPInputError, msg="Invalid multipart/form-data"):
parse_multipart_form_data(b"1234", data, args, files)
self.assertEqual(files, {})
@@ -236,7 +237,9 @@ Foo
b"\n", b"\r\n"
)
args, files = form_data_args()
- with ExpectLog(gen_log, "multipart/form-data value missing name"):
+ with self.assertRaises(
+ HTTPInputError, msg="multipart/form-data value missing name"
+ ):
parse_multipart_form_data(b"1234", data, args, files)
self.assertEqual(files, {})
Index: tornado-6.4/tornado/web.py
===================================================================
--- tornado-6.4.orig/tornado/web.py
+++ tornado-6.4/tornado/web.py
@@ -1751,6 +1751,14 @@ class RequestHandler(object):
try:
if self.request.method not in self.SUPPORTED_METHODS:
raise HTTPError(405)
+
+ # If we're not in stream_request_body mode, this is the place where we parse the body.
+ if not _has_stream_request_body(self.__class__):
+ try:
+ self.request._parse_body()
+ except httputil.HTTPInputError as e:
+ raise HTTPError(400, "Invalid body: %s" % e) from e
+
self.path_args = [self.decode_argument(arg) for arg in args]
self.path_kwargs = dict(
(k, self.decode_argument(v, name=k)) for (k, v) in kwargs.items()
@@ -1941,7 +1949,7 @@ def _has_stream_request_body(cls: Type[R
def removeslash(
- method: Callable[..., Optional[Awaitable[None]]]
+ method: Callable[..., Optional[Awaitable[None]]],
) -> Callable[..., Optional[Awaitable[None]]]:
"""Use this decorator to remove trailing slashes from the request path.
@@ -1970,7 +1978,7 @@ def removeslash(
def addslash(
- method: Callable[..., Optional[Awaitable[None]]]
+ method: Callable[..., Optional[Awaitable[None]]],
) -> Callable[..., Optional[Awaitable[None]]]:
"""Use this decorator to add a missing trailing slash to the request path.
@@ -2394,8 +2402,9 @@ class _HandlerDelegate(httputil.HTTPMess
if self.stream_request_body:
future_set_result_unless_cancelled(self.request._body_future, None)
else:
+ # Note that the body gets parsed in RequestHandler._execute so it can be in
+ # the right exception handler scope.
self.request.body = b"".join(self.chunks)
- self.request._parse_body()
self.execute()
def on_connection_close(self) -> None:
@@ -3267,7 +3276,7 @@ class GZipContentEncoding(OutputTransfor
def authenticated(
- method: Callable[..., Optional[Awaitable[None]]]
+ method: Callable[..., Optional[Awaitable[None]]],
) -> Callable[..., Optional[Awaitable[None]]]:
"""Decorate methods with this to require that the user be logged in.

13
openssl-3.2.patch Normal file
View File

@@ -0,0 +1,13 @@
Index: tornado-6.4/tornado/iostream.py
===================================================================
--- tornado-6.4.orig/tornado/iostream.py
+++ tornado-6.4/tornado/iostream.py
@@ -1374,7 +1374,7 @@ class SSLIOStream(IOStream):
return
elif err.args[0] in (ssl.SSL_ERROR_EOF, ssl.SSL_ERROR_ZERO_RETURN):
return self.close(exc_info=err)
- elif err.args[0] == ssl.SSL_ERROR_SSL:
+ elif err.args[0] in (ssl.SSL_ERROR_SSL, ssl.SSL_ERROR_SYSCALL):
try:
peer = self.socket.getpeername()
except Exception:

View File

@@ -1,53 +1,16 @@
------------------------------------------------------------------- -------------------------------------------------------------------
Mon Nov 25 03:19:20 UTC 2024 - Steve Kowalik <steven.kowalik@suse.com> Mon May 26 10:20:14 UTC 2025 - Daniel Garcia <daniel.garcia@suse.com>
- Update to 6.4.2: - Add patch CVE-2025-47287.patch:
+ Security Improvements: * httputil: Raise errors instead of logging in multipart/form-data parsing
* Parsing of the cookie header is now much more efficient. The older (CVE-2025-47287, bsc#1243268, gh#tornadoweb/tornado#3497)
algorithm sometimes had quadratic performance which allowed for a
denial-of-service attack in which the server would spend excessive
CPU time parsing cookies and block the event loop.
(CVE-2024-52804, bsc#1233668)
------------------------------------------------------------------- -------------------------------------------------------------------
Wed Jul 31 09:32:23 UTC 2024 - Dominique Leuenberger <dimstar@opensuse.org> Wed Nov 27 04:23:13 UTC 2024 - Steve Kowalik <steven.kowalik@suse.com>
- Update to version 6.4.1: - Add patch CVE-2024-52804-avoid-quadratic-cookie-parsing.patch:
+ Security Improvements: * Avoid quadratic performance of cookie parsing.
- Parsing of the ``Transfer-Encoding`` header is now stricter. (CVE-2024-52804, bsc#1233668)
Unexpected transfer-encoding values were previously ignored
and treated as the HTTP/1.0 default of read-until-close. This
can lead to framing issues with certain proxies. We now treat
any unexpected value as an error.
- Handling of whitespace in headers now matches the RFC more
closely. Only space and tab characters are treated as
whitespace and stripped from the beginning and end of header
values. Other unicode whitespace characters are now left
alone. This could also lead to framing issues with certain
proxies.
- `tornado.curl_httpclient` now prohibits carriage return and
linefeed headers in HTTP headers (matching the behavior of
`simple_httpclient`). These characters could be used for
header injection or request smuggling if untrusted data were
used in headers.
+ General Changes:
- `tornado.iostream`: `SLIOStream` now understands changes to
error codes from OpenSSL 3.2. The main result of this change
is to reduce the noise in the logs for certain errors.
- `tornado.simple_httpclient`: `simple_httpclient` now
prohibits carriage return characters in HTTP headers. It had
previously prohibited only linefeed characters.
- `tornado.testing`: `.AsyncTestCase` subclasses can now be
instantiated without being associated with a test method.
Improves compatibility with test discovery in Pytest 8.2.
- Drop support-pytest-8.2.patch: fixed upstream.
- Drop openssl-3.2.patch: fixed upstream.
-------------------------------------------------------------------
Fri May 17 03:37:07 UTC 2024 - Steve Kowalik <steven.kowalik@suse.com>
- Add patch support-pytest-8.2.patch:
* Support pytest >= 8.2 changes.
------------------------------------------------------------------- -------------------------------------------------------------------
Thu Jan 11 13:28:34 UTC 2024 - Daniel Garcia <daniel.garcia@suse.com> Thu Jan 11 13:28:34 UTC 2024 - Daniel Garcia <daniel.garcia@suse.com>

View File

@@ -17,8 +17,9 @@
%{?sle15_python_module_pythons} %{?sle15_python_module_pythons}
%define skip_python2 1
Name: python-tornado6 Name: python-tornado6
Version: 6.4.2 Version: 6.4
Release: 0 Release: 0
Summary: Open source version of scalable, non-blocking web server that power FriendFeed Summary: Open source version of scalable, non-blocking web server that power FriendFeed
License: Apache-2.0 License: Apache-2.0
@@ -27,6 +28,13 @@ Source: https://files.pythonhosted.org/packages/source/t/tornado/tornado
Source99: python-tornado6-rpmlintrc Source99: python-tornado6-rpmlintrc
# PATCH-FIX-OPENSUSE ignore-resourcewarning-doctests.patch -- ignore resource warnings on OBS # PATCH-FIX-OPENSUSE ignore-resourcewarning-doctests.patch -- ignore resource warnings on OBS
Patch0: ignore-resourcewarning-doctests.patch Patch0: ignore-resourcewarning-doctests.patch
# PATCH-FIX-OPENSUSE openssl-3.2.patch gh#tornadoweb/tornado#3355
Patch1: openssl-3.2.patch
# PATCH-FIX-UPSTREAM CVE-2024-52804 bsc#1233668
# gh#tornadoweb/tornado#d5ba4a1695fbf7c6a3e54313262639b198291533
Patch2: CVE-2024-52804-avoid-quadratic-cookie-parsing.patch
# PATCH-FIX-UPSTREAM CVE-2025-47287.patch bsc#1243268
Patch3: CVE-2025-47287.patch
BuildRequires: %{python_module base >= 3.8} BuildRequires: %{python_module base >= 3.8}
BuildRequires: %{python_module devel} BuildRequires: %{python_module devel}
BuildRequires: %{python_module pip} BuildRequires: %{python_module pip}
@@ -103,6 +111,6 @@ export TRAVIS=1
%license LICENSE %license LICENSE
%doc %{_docdir}/%{python_prefix}-tornado6 %doc %{_docdir}/%{python_prefix}-tornado6
%{python_sitearch}/tornado %{python_sitearch}/tornado
%{python_sitearch}/tornado-%{version}.dist-info %{python_sitearch}/tornado-%{version}*-info
%changelog %changelog

BIN
tornado-6.4.2.tar.gz (Stored with Git LFS)

Binary file not shown.

BIN
tornado-6.4.tar.gz (Stored with Git LFS) Normal file

Binary file not shown.