Version in base suite: 6.2.0-3+deb12u1 Base version: python-tornado_6.2.0-3+deb12u1 Target version: python-tornado_6.2.0-3+deb12u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-tornado/python-tornado_6.2.0-3+deb12u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-tornado/python-tornado_6.2.0-3+deb12u2.dsc changelog | 12 ++ patches/CVE-2025-47287.patch | 234 +++++++++++++++++++++++++++++++++++++++++++ patches/series | 1 3 files changed, 247 insertions(+) diff -Nru python-tornado-6.2.0/debian/changelog python-tornado-6.2.0/debian/changelog --- python-tornado-6.2.0/debian/changelog 2024-12-31 00:53:59.000000000 +0000 +++ python-tornado-6.2.0/debian/changelog 2025-06-03 11:27:39.000000000 +0000 @@ -1,3 +1,15 @@ +python-tornado (6.2.0-3+deb12u2) bookworm-security; urgency=medium + + * Non-maintainer upload by the Debian LTS team. + * d/patches/CVE-2025-47287.patch: Add patch to fix CVE-2025-47287. + - When Tornado's 'multipart/form-data' parser encounters certain errors, + it logs a warning but continues trying to parse the remainder of the + data. This allows remote attackers to generate an extremely high volume + of logs, constituting a DoS attack. This DoS is compounded by the fact + that the logging subsystem is synchronous (closes: #1105886). + + -- Daniel Leidert Tue, 03 Jun 2025 13:27:39 +0200 + python-tornado (6.2.0-3+deb12u1) bookworm; urgency=medium * Non-maintainer upload by the Debian LTS team. diff -Nru python-tornado-6.2.0/debian/patches/CVE-2025-47287.patch python-tornado-6.2.0/debian/patches/CVE-2025-47287.patch --- python-tornado-6.2.0/debian/patches/CVE-2025-47287.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-tornado-6.2.0/debian/patches/CVE-2025-47287.patch 2025-06-03 11:27:39.000000000 +0000 @@ -0,0 +1,234 @@ +From: Ben Darnell +Date: Thu, 8 May 2025 13:29:43 -0400 +Subject: 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. + +Reviewed-By: Daniel Leidert +Origin: https://github.com/tornadoweb/tornado/pull/3497 +Bug: https://github.com/tornadoweb/tornado/security/advisories/GHSA-7cx3-6m66-7c5m +Bug-Debian: https://bugs.debian.org/1105886 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-47287 +Bug-Freexian-Security: https://deb.freexian.com/extended-lts/tracker/CVE-2025-47287 +--- + tornado/httputil.py | 29 +++++++++++------------------ + tornado/test/httpserver_test.py | 4 ++-- + tornado/test/httputil_test.py | 13 ++++++++----- + tornado/web.py | 17 +++++++++++++---- + 4 files changed, 34 insertions(+), 29 deletions(-) + +diff --git a/tornado/httputil.py b/tornado/httputil.py +index f74eced..80c159c 100644 +--- a/tornado/httputil.py ++++ b/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,23 @@ 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) ++ raise HTTPInputError("Invalid x-www-form-urlencoded body: %s" % e) from e + uri_arguments = {} + 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 +783,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 +814,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") +diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py +index 367a0ef..3fff206 100644 +--- a/tornado/test/httpserver_test.py ++++ b/tornado/test/httpserver_test.py +@@ -1025,9 +1025,9 @@ class GzipUnsupportedTest(GzipBaseTest, AsyncHTTPTestCase): + # 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): +diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py +index 25faf66..4ceda3b 100644 +--- a/tornado/test/httputil_test.py ++++ b/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 + + import copy + import datetime +@@ -194,7 +193,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, {}) + +@@ -208,7 +209,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, {}) + +@@ -221,7 +222,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, {}) + +@@ -235,7 +236,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, {}) + +diff --git a/tornado/web.py b/tornado/web.py +index 05b571e..98181c0 100644 +--- a/tornado/web.py ++++ b/tornado/web.py +@@ -1670,6 +1670,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() +@@ -1864,7 +1872,7 @@ def _has_stream_request_body(cls: Type[RequestHandler]) -> bool: + + + 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. + +@@ -1893,7 +1901,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. + +@@ -2317,8 +2325,9 @@ class _HandlerDelegate(httputil.HTTPMessageDelegate): + 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: +@@ -3183,7 +3192,7 @@ class GZipContentEncoding(OutputTransform): + + + 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. + diff -Nru python-tornado-6.2.0/debian/patches/series python-tornado-6.2.0/debian/patches/series --- python-tornado-6.2.0/debian/patches/series 2024-12-31 00:53:59.000000000 +0000 +++ python-tornado-6.2.0/debian/patches/series 2025-06-03 11:27:39.000000000 +0000 @@ -7,3 +7,4 @@ CVE-2024-52804.patch CVE-2023-28370-1.patch CVE-2023-28370-2.patch +CVE-2025-47287.patch