diff options
Diffstat (limited to 'meta-python/recipes-devtools/python/python3-tornado')
| -rw-r--r-- | meta-python/recipes-devtools/python/python3-tornado/CVE-2025-47287.patch | 232 |
1 files changed, 232 insertions, 0 deletions
diff --git a/meta-python/recipes-devtools/python/python3-tornado/CVE-2025-47287.patch b/meta-python/recipes-devtools/python/python3-tornado/CVE-2025-47287.patch new file mode 100644 index 0000000000..02439c43e0 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-tornado/CVE-2025-47287.patch | |||
| @@ -0,0 +1,232 @@ | |||
| 1 | From 85a6a33e774376ec5b286d3a4857c569b8a8c4a8 Mon Sep 17 00:00:00 2001 | ||
| 2 | From: Ben Darnell <ben@bendarnell.com> | ||
| 3 | Date: Thu, 8 May 2025 13:29:43 -0400 | ||
| 4 | Subject: [PATCH] httputil: Raise errors instead of logging in | ||
| 5 | multipart/form-data parsing | ||
| 6 | |||
| 7 | We used to continue after logging an error, which allowed repeated | ||
| 8 | errors to spam the logs. The error raised here will still be logged, | ||
| 9 | but only once per request, consistent with other error handling in | ||
| 10 | Tornado. | ||
| 11 | |||
| 12 | CVE: CVE-2025-47287 | ||
| 13 | Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/cc61050e8f26697463142d99864b562e8470b41d] | ||
| 14 | Signed-off-by: Ankur Tyagi <ankur.tyagi85@gmail.com> | ||
| 15 | --- | ||
| 16 | tornado/httputil.py | 30 +++++++++++------------------- | ||
| 17 | tornado/test/httpserver_test.py | 4 ++-- | ||
| 18 | tornado/test/httputil_test.py | 13 ++++++++----- | ||
| 19 | tornado/web.py | 17 +++++++++++++---- | ||
| 20 | 4 files changed, 34 insertions(+), 30 deletions(-) | ||
| 21 | |||
| 22 | diff --git a/tornado/httputil.py b/tornado/httputil.py | ||
| 23 | index ebdc8059..090a977d 100644 | ||
| 24 | --- a/tornado/httputil.py | ||
| 25 | +++ b/tornado/httputil.py | ||
| 26 | @@ -34,7 +34,6 @@ import unicodedata | ||
| 27 | from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl | ||
| 28 | |||
| 29 | from tornado.escape import native_str, parse_qs_bytes, utf8 | ||
| 30 | -from tornado.log import gen_log | ||
| 31 | from tornado.util import ObjectDict, unicode_type | ||
| 32 | |||
| 33 | |||
| 34 | @@ -762,25 +761,22 @@ def parse_body_arguments( | ||
| 35 | """ | ||
| 36 | if content_type.startswith("application/x-www-form-urlencoded"): | ||
| 37 | if headers and "Content-Encoding" in headers: | ||
| 38 | - gen_log.warning( | ||
| 39 | - "Unsupported Content-Encoding: %s", headers["Content-Encoding"] | ||
| 40 | + raise HTTPInputError( | ||
| 41 | + "Unsupported Content-Encoding: %s" % headers["Content-Encoding"] | ||
| 42 | ) | ||
| 43 | - return | ||
| 44 | try: | ||
| 45 | # real charset decoding will happen in RequestHandler.decode_argument() | ||
| 46 | uri_arguments = parse_qs_bytes(body, keep_blank_values=True) | ||
| 47 | except Exception as e: | ||
| 48 | - gen_log.warning("Invalid x-www-form-urlencoded body: %s", e) | ||
| 49 | - uri_arguments = {} | ||
| 50 | + raise HTTPInputError("Invalid x-www-form-urlencoded body: %s" % e) from e | ||
| 51 | for name, values in uri_arguments.items(): | ||
| 52 | if values: | ||
| 53 | arguments.setdefault(name, []).extend(values) | ||
| 54 | elif content_type.startswith("multipart/form-data"): | ||
| 55 | if headers and "Content-Encoding" in headers: | ||
| 56 | - gen_log.warning( | ||
| 57 | - "Unsupported Content-Encoding: %s", headers["Content-Encoding"] | ||
| 58 | + raise HTTPInputError( | ||
| 59 | + "Unsupported Content-Encoding: %s" % headers["Content-Encoding"] | ||
| 60 | ) | ||
| 61 | - return | ||
| 62 | try: | ||
| 63 | fields = content_type.split(";") | ||
| 64 | for field in fields: | ||
| 65 | @@ -789,9 +785,9 @@ def parse_body_arguments( | ||
| 66 | parse_multipart_form_data(utf8(v), body, arguments, files) | ||
| 67 | break | ||
| 68 | else: | ||
| 69 | - raise ValueError("multipart boundary not found") | ||
| 70 | + raise HTTPInputError("multipart boundary not found") | ||
| 71 | except Exception as e: | ||
| 72 | - gen_log.warning("Invalid multipart/form-data: %s", e) | ||
| 73 | + raise HTTPInputError("Invalid multipart/form-data: %s" % e) from e | ||
| 74 | |||
| 75 | |||
| 76 | def parse_multipart_form_data( | ||
| 77 | @@ -820,26 +816,22 @@ def parse_multipart_form_data( | ||
| 78 | boundary = boundary[1:-1] | ||
| 79 | final_boundary_index = data.rfind(b"--" + boundary + b"--") | ||
| 80 | if final_boundary_index == -1: | ||
| 81 | - gen_log.warning("Invalid multipart/form-data: no final boundary") | ||
| 82 | - return | ||
| 83 | + raise HTTPInputError("Invalid multipart/form-data: no final boundary found") | ||
| 84 | parts = data[:final_boundary_index].split(b"--" + boundary + b"\r\n") | ||
| 85 | for part in parts: | ||
| 86 | if not part: | ||
| 87 | continue | ||
| 88 | eoh = part.find(b"\r\n\r\n") | ||
| 89 | if eoh == -1: | ||
| 90 | - gen_log.warning("multipart/form-data missing headers") | ||
| 91 | - continue | ||
| 92 | + raise HTTPInputError("multipart/form-data missing headers") | ||
| 93 | headers = HTTPHeaders.parse(part[:eoh].decode("utf-8")) | ||
| 94 | disp_header = headers.get("Content-Disposition", "") | ||
| 95 | disposition, disp_params = _parse_header(disp_header) | ||
| 96 | if disposition != "form-data" or not part.endswith(b"\r\n"): | ||
| 97 | - gen_log.warning("Invalid multipart/form-data") | ||
| 98 | - continue | ||
| 99 | + raise HTTPInputError("Invalid multipart/form-data") | ||
| 100 | value = part[eoh + 4 : -2] | ||
| 101 | if not disp_params.get("name"): | ||
| 102 | - gen_log.warning("multipart/form-data value missing name") | ||
| 103 | - continue | ||
| 104 | + raise HTTPInputError("multipart/form-data missing name") | ||
| 105 | name = disp_params["name"] | ||
| 106 | if disp_params.get("filename"): | ||
| 107 | ctype = headers.get("Content-Type", "application/unknown") | ||
| 108 | diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py | ||
| 109 | index 0b29a39c..5d5fb13a 100644 | ||
| 110 | --- a/tornado/test/httpserver_test.py | ||
| 111 | +++ b/tornado/test/httpserver_test.py | ||
| 112 | @@ -1131,9 +1131,9 @@ class GzipUnsupportedTest(GzipBaseTest, AsyncHTTPTestCase): | ||
| 113 | # Gzip support is opt-in; without it the server fails to parse | ||
| 114 | # the body (but parsing form bodies is currently just a log message, | ||
| 115 | # not a fatal error). | ||
| 116 | - with ExpectLog(gen_log, "Unsupported Content-Encoding"): | ||
| 117 | + with ExpectLog(gen_log, ".*Unsupported Content-Encoding"): | ||
| 118 | response = self.post_gzip("foo=bar") | ||
| 119 | - self.assertEqual(json_decode(response.body), {}) | ||
| 120 | + self.assertEqual(response.code, 400) | ||
| 121 | |||
| 122 | |||
| 123 | class StreamingChunkSizeTest(AsyncHTTPTestCase): | ||
| 124 | diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py | ||
| 125 | index 975900aa..9494d0c1 100644 | ||
| 126 | --- a/tornado/test/httputil_test.py | ||
| 127 | +++ b/tornado/test/httputil_test.py | ||
| 128 | @@ -12,7 +12,6 @@ from tornado.httputil import ( | ||
| 129 | ) | ||
| 130 | from tornado.escape import utf8, native_str | ||
| 131 | from tornado.log import gen_log | ||
| 132 | -from tornado.testing import ExpectLog | ||
| 133 | from tornado.test.util import ignore_deprecation | ||
| 134 | |||
| 135 | import copy | ||
| 136 | @@ -195,7 +194,9 @@ Foo | ||
| 137 | b"\n", b"\r\n" | ||
| 138 | ) | ||
| 139 | args, files = form_data_args() | ||
| 140 | - with ExpectLog(gen_log, "multipart/form-data missing headers"): | ||
| 141 | + with self.assertRaises( | ||
| 142 | + HTTPInputError, msg="multipart/form-data missing headers" | ||
| 143 | + ): | ||
| 144 | parse_multipart_form_data(b"1234", data, args, files) | ||
| 145 | self.assertEqual(files, {}) | ||
| 146 | |||
| 147 | @@ -209,7 +210,7 @@ Foo | ||
| 148 | b"\n", b"\r\n" | ||
| 149 | ) | ||
| 150 | args, files = form_data_args() | ||
| 151 | - with ExpectLog(gen_log, "Invalid multipart/form-data"): | ||
| 152 | + with self.assertRaises(HTTPInputError, msg="Invalid multipart/form-data"): | ||
| 153 | parse_multipart_form_data(b"1234", data, args, files) | ||
| 154 | self.assertEqual(files, {}) | ||
| 155 | |||
| 156 | @@ -222,7 +223,7 @@ Foo--1234--""".replace( | ||
| 157 | b"\n", b"\r\n" | ||
| 158 | ) | ||
| 159 | args, files = form_data_args() | ||
| 160 | - with ExpectLog(gen_log, "Invalid multipart/form-data"): | ||
| 161 | + with self.assertRaises(HTTPInputError, msg="Invalid multipart/form-data"): | ||
| 162 | parse_multipart_form_data(b"1234", data, args, files) | ||
| 163 | self.assertEqual(files, {}) | ||
| 164 | |||
| 165 | @@ -236,7 +237,9 @@ Foo | ||
| 166 | b"\n", b"\r\n" | ||
| 167 | ) | ||
| 168 | args, files = form_data_args() | ||
| 169 | - with ExpectLog(gen_log, "multipart/form-data value missing name"): | ||
| 170 | + with self.assertRaises( | ||
| 171 | + HTTPInputError, msg="multipart/form-data value missing name" | ||
| 172 | + ): | ||
| 173 | parse_multipart_form_data(b"1234", data, args, files) | ||
| 174 | self.assertEqual(files, {}) | ||
| 175 | |||
| 176 | diff --git a/tornado/web.py b/tornado/web.py | ||
| 177 | index 03939647..8ec5601b 100644 | ||
| 178 | --- a/tornado/web.py | ||
| 179 | +++ b/tornado/web.py | ||
| 180 | @@ -1751,6 +1751,14 @@ class RequestHandler(object): | ||
| 181 | try: | ||
| 182 | if self.request.method not in self.SUPPORTED_METHODS: | ||
| 183 | raise HTTPError(405) | ||
| 184 | + | ||
| 185 | + # If we're not in stream_request_body mode, this is the place where we parse the body. | ||
| 186 | + if not _has_stream_request_body(self.__class__): | ||
| 187 | + try: | ||
| 188 | + self.request._parse_body() | ||
| 189 | + except httputil.HTTPInputError as e: | ||
| 190 | + raise HTTPError(400, "Invalid body: %s" % e) from e | ||
| 191 | + | ||
| 192 | self.path_args = [self.decode_argument(arg) for arg in args] | ||
| 193 | self.path_kwargs = dict( | ||
| 194 | (k, self.decode_argument(v, name=k)) for (k, v) in kwargs.items() | ||
| 195 | @@ -1941,7 +1949,7 @@ def _has_stream_request_body(cls: Type[RequestHandler]) -> bool: | ||
| 196 | |||
| 197 | |||
| 198 | def removeslash( | ||
| 199 | - method: Callable[..., Optional[Awaitable[None]]] | ||
| 200 | + method: Callable[..., Optional[Awaitable[None]]], | ||
| 201 | ) -> Callable[..., Optional[Awaitable[None]]]: | ||
| 202 | """Use this decorator to remove trailing slashes from the request path. | ||
| 203 | |||
| 204 | @@ -1970,7 +1978,7 @@ def removeslash( | ||
| 205 | |||
| 206 | |||
| 207 | def addslash( | ||
| 208 | - method: Callable[..., Optional[Awaitable[None]]] | ||
| 209 | + method: Callable[..., Optional[Awaitable[None]]], | ||
| 210 | ) -> Callable[..., Optional[Awaitable[None]]]: | ||
| 211 | """Use this decorator to add a missing trailing slash to the request path. | ||
| 212 | |||
| 213 | @@ -2394,8 +2402,9 @@ class _HandlerDelegate(httputil.HTTPMessageDelegate): | ||
| 214 | if self.stream_request_body: | ||
| 215 | future_set_result_unless_cancelled(self.request._body_future, None) | ||
| 216 | else: | ||
| 217 | + # Note that the body gets parsed in RequestHandler._execute so it can be in | ||
| 218 | + # the right exception handler scope. | ||
| 219 | self.request.body = b"".join(self.chunks) | ||
| 220 | - self.request._parse_body() | ||
| 221 | self.execute() | ||
| 222 | |||
| 223 | def on_connection_close(self) -> None: | ||
| 224 | @@ -3267,7 +3276,7 @@ class GZipContentEncoding(OutputTransform): | ||
| 225 | |||
| 226 | |||
| 227 | def authenticated( | ||
| 228 | - method: Callable[..., Optional[Awaitable[None]]] | ||
| 229 | + method: Callable[..., Optional[Awaitable[None]]], | ||
| 230 | ) -> Callable[..., Optional[Awaitable[None]]]: | ||
| 231 | """Decorate methods with this to require that the user be logged in. | ||
| 232 | |||
