From: Ben Darnell Date: Thu, 6 Aug 2026 01:20:58 +0000 (-0400) Subject: httputil: Enforce a new limit on the number of arguments in a request X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=fa84fca9e3b7c8ea5c5bc31b9515e68befaab7ef;p=thirdparty%2Ftornado.git httputil: Enforce a new limit on the number of arguments in a request Large POST bodies can be very expensive to parse in the worst case, so use the (new in Python 3.8) max_num_fields argument to limit the cost. A new field in ParseBodyConfig allows users to configure this limit. The default is 1000, which is the same as that used in php and node.js. --- diff --git a/tornado/escape.py b/tornado/escape.py index 1241c9f9..c67aecb9 100644 --- a/tornado/escape.py +++ b/tornado/escape.py @@ -169,7 +169,11 @@ def url_unescape( def parse_qs_bytes( - qs: str | bytes, keep_blank_values: bool = False, strict_parsing: bool = False + qs: str | bytes, + keep_blank_values: bool = False, + strict_parsing: bool = False, + *, + max_num_fields: int | None = None, ) -> dict[str, list[bytes]]: """Parses a query string like urlparse.parse_qs, but takes bytes and returns the values as byte strings. @@ -177,13 +181,21 @@ def parse_qs_bytes( Keys still become type str (interpreted as latin1 in python3!) because it's too painful to keep them as byte strings in python3 and in practice they're nearly always ascii anyway. + + .. versionadded:: 6.5.8 + The ``max_num_fields`` argument. ValueError is raised if this limit is exceeded. """ # This is gross, but python3 doesn't give us another way. # Latin1 is the universal donor of character encodings. if isinstance(qs, bytes): qs = qs.decode("latin1") result = urllib.parse.parse_qs( - qs, keep_blank_values, strict_parsing, encoding="latin1", errors="strict" + qs, + keep_blank_values, + strict_parsing, + encoding="latin1", + errors="strict", + max_num_fields=max_num_fields, ) encoded = {} for k, v in result.items(): diff --git a/tornado/httputil.py b/tornado/httputil.py index 03e1f638..42ac738e 100644 --- a/tornado/httputil.py +++ b/tornado/httputil.py @@ -959,6 +959,23 @@ class ParseMultipartConfig: """ +@dataclasses.dataclass +class ParseUrlEncodedConfig: + """This class configures the parsing of ``application/x-www-form-urlencoded`` request bodies. + + Its primary purpose is to place limits on the size and complexity of request messages + to avoid potential denial-of-service attacks. + + .. versionadded:: 6.5.8 + """ + + max_arguments: int = 1000 + """The maximum number of arguments accepted in a urlencoded request. + + Each ```` element in an HTML form corresponds to at least one argument. + """ + + @dataclasses.dataclass class ParseBodyConfig: """This class configures the parsing of request bodies. @@ -969,6 +986,9 @@ class ParseBodyConfig: multipart: ParseMultipartConfig = dataclasses.field( default_factory=ParseMultipartConfig ) + urlencoded: ParseUrlEncodedConfig = dataclasses.field( + default_factory=ParseUrlEncodedConfig + ) """Configuration for ``multipart/form-data`` request bodies.""" @@ -1027,7 +1047,11 @@ def parse_body_arguments( ) try: # real charset decoding will happen in RequestHandler.decode_argument() - uri_arguments = parse_qs_bytes(body, keep_blank_values=True) + uri_arguments = parse_qs_bytes( + body, + keep_blank_values=True, + max_num_fields=config.urlencoded.max_arguments, + ) except Exception as e: raise HTTPInputError("Invalid x-www-form-urlencoded body: %s" % e) from e for name, values in uri_arguments.items(): diff --git a/tornado/test/httputil_test.py b/tornado/test/httputil_test.py index 4e966eb5..619527d7 100644 --- a/tornado/test/httputil_test.py +++ b/tornado/test/httputil_test.py @@ -15,6 +15,7 @@ from tornado.httputil import ( ParseMultipartConfig, RequestStartLine, format_timestamp, + parse_body_arguments, parse_cookie, parse_multipart_form_data, parse_request_start_line, @@ -94,6 +95,23 @@ class QsParseTest(unittest.TestCase): self.assertIn(("b", "2"), qsl) +class UrlEncodedDataTest(unittest.TestCase): + def test_urlencoded_data(self): + data = b"a=1&b=2&a=3" + args, files = form_data_args() + parse_body_arguments("application/x-www-form-urlencoded", data, args, files) + self.assertEqual(args["a"], [b"1", b"3"]) + self.assertEqual(args["b"], [b"2"]) + self.assertEqual(files, {}) + + def test_max_arguments(self): + data = b"".join(b"a=1&" for _ in range(1001)) + args, files = form_data_args() + with self.assertRaises(HTTPInputError) as cm: + parse_body_arguments("application/x-www-form-urlencoded", data, args, files) + self.assertIn("Max number of fields exceeded", str(cm.exception)) + + class MultipartFormDataTest(unittest.TestCase): def test_file_upload(self): data = b"""\