From dba1c4babc4f99ad2622bb913d87045775dda735 Mon Sep 17 00:00:00 2001
From: Marcelo Trylesinski <marcelotryle@gmail.com>
Date: Fri, 12 Jun 2026 11:03:48 +0200
Subject: [PATCH] Enforce `max_fields` and `max_part_size` in `FormParser`
 (#3329)

---
 starlette/formparsers.py  |  19 +++++-
 starlette/requests.py     |  14 ++++-
 tests/test_formparsers.py | 124 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 154 insertions(+), 3 deletions(-)

Index: starlette-0.41.3/starlette/formparsers.py
===================================================================
--- starlette-0.41.3.orig/starlette/formparsers.py
+++ starlette-0.41.3/starlette/formparsers.py
@@ -54,10 +54,19 @@ class MultiPartException(Exception):
 
 
 class FormParser:
-    def __init__(self, headers: Headers, stream: typing.AsyncGenerator[bytes, None]) -> None:
+    def __init__(
+        self,
+        headers: Headers,
+        stream: AsyncGenerator[bytes, None],
+        *,
+        max_fields: int | float = 1000,
+        max_part_size: int = 1024 * 1024,  # 1MB
+    ) -> None:
         assert multipart is not None, "The `python-multipart` library must be installed to use form parsing."
         self.headers = headers
         self.stream = stream
+        self.max_fields = max_fields
+        self.max_part_size = max_part_size
         self.messages: list[tuple[FormMessage, bytes]] = []
 
     def on_field_start(self) -> None:
@@ -96,6 +105,7 @@ class FormParser:
         field_value = b""
 
         items: list[tuple[str, str | UploadFile]] = []
+        field_count = 0
 
         # Feed the parser with data from the request.
         async for chunk in self.stream:
@@ -110,10 +120,17 @@ class FormParser:
                     field_name = b""
                     field_value = b""
                 elif message_type == FormMessage.FIELD_NAME:
+                    if len(field_name) + len(field_value) + len(message_bytes) > self.max_part_size:
+                        raise MultiPartException(f"Field exceeded maximum size of {int(self.max_part_size / 1024)}KB.")
                     field_name += message_bytes
                 elif message_type == FormMessage.FIELD_DATA:
+                    if len(field_name) + len(field_value) + len(message_bytes) > self.max_part_size:
+                        raise MultiPartException(f"Field exceeded maximum size of {int(self.max_part_size / 1024)}KB.")
                     field_value += message_bytes
                 elif message_type == FormMessage.FIELD_END:
+                    field_count += 1
+                    if field_count > self.max_fields:
+                        raise MultiPartException(f"Too many fields. Maximum number of fields is {self.max_fields}.")
                     name = unquote_plus(field_name.decode("latin-1"))
                     value = unquote_plus(field_value.decode("latin-1"))
                     items.append((name, value))
@@ -124,9 +141,7 @@ class FormParser:
 class MultiPartParser:
     spool_max_size = 1024 * 1024  # 1MB
     """The maximum size of the spooled temporary file used to store file data."""
-    max_part_size = 1024 * 1024  # 1MB
-    """The maximum size of a part in the multipart request."""
-    max_part_size = 1024 * 1024  # 1MB
+    max_file_size = 1024 * 1024  # 1MB
 
     def __init__(
         self,
@@ -135,6 +150,7 @@ class MultiPartParser:
         *,
         max_files: int | float = 1000,
         max_fields: int | float = 1000,
+        max_part_size: int = 1024 * 1024,  # 1MB
     ) -> None:
         assert multipart is not None, "The `python-multipart` library must be installed to use form parsing."
         self.headers = headers
@@ -151,6 +167,7 @@ class MultiPartParser:
         self._file_parts_to_write: list[tuple[MultipartPart, bytes]] = []
         self._file_parts_to_finish: list[MultipartPart] = []
         self._files_to_close_on_error: list[SpooledTemporaryFile[bytes]] = []
+        self.max_part_size = max_part_size
 
     def on_part_begin(self) -> None:
         self._current_part = MultipartPart()
Index: starlette-0.41.3/starlette/requests.py
===================================================================
--- starlette-0.41.3.orig/starlette/requests.py
+++ starlette-0.41.3/starlette/requests.py
@@ -249,7 +249,13 @@ class Request(HTTPConnection):
             self._json = json.loads(body)
         return self._json
 
-    async def _get_form(self, *, max_files: int | float = 1000, max_fields: int | float = 1000) -> FormData:
+    async def _get_form(
+        self,
+        *,
+        max_files: int | float = 1000,
+        max_fields: int | float = 1000,
+        max_part_size: int = 1024 * 1024,
+    ) -> FormData:
         if self._form is None:
             assert (
                 parse_options_header is not None
@@ -264,6 +270,7 @@ class Request(HTTPConnection):
                         self.stream(),
                         max_files=max_files,
                         max_fields=max_fields,
+                        max_part_size=max_part_size,
                     )
                     self._form = await multipart_parser.parse()
                 except MultiPartException as exc:
@@ -271,16 +278,32 @@ class Request(HTTPConnection):
                         raise HTTPException(status_code=400, detail=exc.message)
                     raise exc
             elif content_type == b"application/x-www-form-urlencoded":
-                form_parser = FormParser(self.headers, self.stream())
-                self._form = await form_parser.parse()
+                try:
+                    form_parser = FormParser(
+                        self.headers,
+                        self.stream(),
+                        max_fields=max_fields,
+                        max_part_size=max_part_size,
+                    )
+                    self._form = await form_parser.parse()
+                except MultiPartException as exc:
+                    if "app" in self.scope:
+                        raise HTTPException(status_code=400, detail=exc.message)
+                    raise exc
             else:
                 self._form = FormData()
         return self._form
 
     def form(
-        self, *, max_files: int | float = 1000, max_fields: int | float = 1000
+        self,
+        *,
+        max_files: int | float = 1000,
+        max_fields: int | float = 1000,
+        max_part_size: int = 1024 * 1024,
     ) -> AwaitableOrContextManager[FormData]:
-        return AwaitableOrContextManagerWrapper(self._get_form(max_files=max_files, max_fields=max_fields))
+        return AwaitableOrContextManagerWrapper(
+            self._get_form(max_files=max_files, max_fields=max_fields, max_part_size=max_part_size)
+        )
 
     async def close(self) -> None:
         if self._form is not None:  # pragma: no branch
Index: starlette-0.41.3/tests/test_formparsers.py
===================================================================
--- starlette-0.41.3.orig/tests/test_formparsers.py
+++ starlette-0.41.3/tests/test_formparsers.py
@@ -126,10 +126,10 @@ async def app_monitor_thread(scope: Scop
     await response(scope, receive, send)
 
 
-def make_app_max_parts(max_files: int = 1000, max_fields: int = 1000) -> ASGIApp:
+def make_app_max_parts(max_files: int = 1000, max_fields: int = 1000, max_part_size: int = 1024 * 1024) -> ASGIApp:
     async def app(scope: Scope, receive: Receive, send: Send) -> None:
         request = Request(scope, receive)
-        data = await request.form(max_files=max_files, max_fields=max_fields)
+        data = await request.form(max_files=max_files, max_fields=max_fields, max_part_size=max_part_size)
         output: dict[str, typing.Any] = {}
         for key, value in data.items():
             if isinstance(value, UploadFile):
@@ -507,6 +507,130 @@ def test_multipart_multi_field_app_reads
     assert response.json() == {"some": "data", "second": "key pair"}
 
 
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (app, pytest.raises(MultiPartException)),
+        (Starlette(routes=[Mount("/", app=app)]), does_not_raise()),
+    ],
+)
+def test_urlencoded_too_many_fields_raise(
+    app: ASGIApp,
+    expectation: AbstractContextManager[Exception],
+    test_client_factory: TestClientFactory,
+) -> None:
+    client = test_client_factory(app)
+    data = "&".join(f"N{i}=" for i in range(1001))
+    with expectation:
+        res = client.post(
+            "/",
+            content=data,
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        assert res.status_code == 400
+        assert res.text == "Too many fields. Maximum number of fields is 1000."
+
+
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (app, pytest.raises(MultiPartException)),
+        (Starlette(routes=[Mount("/", app=app)]), does_not_raise()),
+    ],
+)
+def test_urlencoded_field_exceeds_max_part_size_raise(
+    app: ASGIApp,
+    expectation: AbstractContextManager[Exception],
+    test_client_factory: TestClientFactory,
+) -> None:
+    client = test_client_factory(app)
+    data = "field=" + "x" * (1024 * 1024 + 1)
+    with expectation:
+        res = client.post(
+            "/",
+            content=data,
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        assert res.status_code == 400
+        assert res.text == "Field exceeded maximum size of 1024KB."
+
+
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (app, pytest.raises(MultiPartException)),
+        (Starlette(routes=[Mount("/", app=app)]), does_not_raise()),
+    ],
+)
+def test_urlencoded_field_name_exceeds_max_part_size_raise(
+    app: ASGIApp,
+    expectation: AbstractContextManager[Exception],
+    test_client_factory: TestClientFactory,
+) -> None:
+    client = test_client_factory(app)
+    data = "x" * (1024 * 1024 + 1) + "=value"
+    with expectation:
+        res = client.post(
+            "/",
+            content=data,
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        assert res.status_code == 400
+        assert res.text == "Field exceeded maximum size of 1024KB."
+
+
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (make_app_max_parts(max_fields=1), pytest.raises(MultiPartException)),
+        (
+            Starlette(routes=[Mount("/", app=make_app_max_parts(max_fields=1))]),
+            does_not_raise(),
+        ),
+    ],
+)
+def test_urlencoded_max_fields_is_customizable(
+    app: ASGIApp,
+    expectation: AbstractContextManager[Exception],
+    test_client_factory: TestClientFactory,
+) -> None:
+    client = test_client_factory(app)
+    with expectation:
+        res = client.post(
+            "/",
+            content="a=1&b=2",
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        assert res.status_code == 400
+        assert res.text == "Too many fields. Maximum number of fields is 1."
+
+
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (make_app_max_parts(max_part_size=1024 * 10), pytest.raises(MultiPartException)),
+        (
+            Starlette(routes=[Mount("/", app=make_app_max_parts(max_part_size=1024 * 10))]),
+            does_not_raise(),
+        ),
+    ],
+)
+def test_urlencoded_max_part_size_is_customizable(
+    app: ASGIApp,
+    expectation: AbstractContextManager[Exception],
+    test_client_factory: TestClientFactory,
+) -> None:
+    client = test_client_factory(app)
+    with expectation:
+        res = client.post(
+            "/",
+            content="field=" + "x" * (1024 * 10 + 1),
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        assert res.status_code == 400
+        assert res.text == "Field exceeded maximum size of 10KB."
+
+
 def test_user_safe_decode_helper() -> None:
     result = _user_safe_decode(b"\xc4\x99\xc5\xbc\xc4\x87", "utf-8")
     assert result == "ężć"
@@ -803,3 +927,43 @@ def test_max_part_size_exceeds_limit(
         response = client.post("/", data=multipart_data, headers=headers)  # type: ignore
         assert response.status_code == 400
         assert response.text == "Part exceeded maximum size of 1024KB."
+
+
+@pytest.mark.parametrize(
+    "app,expectation",
+    [
+        (make_app_max_parts(max_part_size=1024 * 10), pytest.raises(MultiPartException)),
+        (
+            Starlette(routes=[Mount("/", app=make_app_max_parts(max_part_size=1024 * 10))]),
+            does_not_raise(),
+        ),
+    ],
+)
+def test_max_part_size_exceeds_custom_limit(
+    app: ASGIApp,
+    expectation: typing.ContextManager[Exception],
+    test_client_factory: TestClientFactory,
+) -> None:
+    client = test_client_factory(app)
+    boundary = "------------------------4K1ON9fZkj9uCUmqLHRbbR"
+
+    multipart_data = (
+        f"--{boundary}\r\n"
+        f'Content-Disposition: form-data; name="small"\r\n\r\n'
+        "small content\r\n"
+        f"--{boundary}\r\n"
+        f'Content-Disposition: form-data; name="large"\r\n\r\n'
+        + ("x" * 1024 * 10 + "x")  # 1MB + 1 byte of data
+        + "\r\n"
+        f"--{boundary}--\r\n"
+    ).encode("utf-8")
+
+    headers = {
+        "Content-Type": f"multipart/form-data; boundary={boundary}",
+        "Transfer-Encoding": "chunked",
+    }
+
+    with expectation:
+        response = client.post("/", content=multipart_data, headers=headers)
+        assert response.status_code == 400
+        assert response.text == "Part exceeded maximum size of 10KB."
Index: starlette-0.41.3/docs/requests.md
===================================================================
--- starlette-0.41.3.orig/docs/requests.md
+++ starlette-0.41.3/docs/requests.md
@@ -113,12 +113,12 @@ state with `disconnected = await request
 
 Request files are normally sent as multipart form data (`multipart/form-data`).
 
-Signature: `request.form(max_files=1000, max_fields=1000)`
+Signature: `request.form(max_files=1000, max_fields=1000, max_part_size=1024*1024)`
 
-You can configure the number of maximum fields or files with the parameters `max_files` and `max_fields`:
+You can configure the number of maximum fields or files with the parameters `max_files` and `max_fields`; and part size using `max_part_size`:
 
 ```python
-async with request.form(max_files=1000, max_fields=1000):
+async with request.form(max_files=1000, max_fields=1000, max_part_size=1024*1024):
     ...
 ```
 
