]> git.ipfire.org Git - thirdparty/tornado.git/commitdiff
httputil: Enforce a new limit on the number of arguments in a request
authorBen Darnell <ben@bendarnell.com>
Thu, 6 Aug 2026 01:20:58 +0000 (21:20 -0400)
committerBen Darnell <ben@bendarnell.com>
Fri, 7 Aug 2026 02:45:29 +0000 (22:45 -0400)
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.

tornado/escape.py
tornado/httputil.py
tornado/test/httputil_test.py

index 1241c9f92fcdff42c0b4f1f9ad37a7f495449a54..c67aecb9fc42587e1879767f38b4efd3e15954a1 100644 (file)
@@ -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():
index 03e1f638cbb4dc65ee09d70c70cdc66c114e628e..42ac738e3ca9b807a38a299751d79bae7cbbf4d0 100644 (file)
@@ -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 ``<input>`` 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():
index 4e966eb50bce0f407fd0ce31cf4ef710e31cd17d..619527d721640c6715853789917693de85946c6d 100644 (file)
@@ -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"""\