From 72454fd83bca4e54f451b97a0448df7fd68b50ea Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 3 Dec 2025 22:28:08 +0100 Subject: [PATCH 1/3] Add `form_max_**` parameters for `post` --- fastapi/applications.py | 36 ++++++++++++++++++++++ fastapi/routing.py | 66 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index 02193312b..f7f36aff6 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -2624,6 +2624,39 @@ class FastAPI(Starlette): """ ), ] = Default(generate_unique_id), + form_max_fields: Annotated[ + int, + Doc( + """ + Maximum number of form fields to accept. + + This limits the number of fields in a form submission to prevent + potential denial-of-service attacks. + """ + ), + ] = 1000, + form_max_files: Annotated[ + int, + Doc( + """ + Maximum number of files to accept in a form submission. + + This limits the number of files in a form submission to prevent + potential denial-of-service attacks. + """ + ), + ] = 1000, + form_max_part_size: Annotated[ + int, + Doc( + """ + Maximum size (in bytes) for each part in a form submission. + + This limits the size of each part in a form submission to prevent + potential denial-of-service attacks. + """ + ), + ] = 1024 * 1024, ) -> Callable[[DecoratedCallable], DecoratedCallable]: """ Add a *path operation* using an HTTP POST operation. @@ -2669,6 +2702,9 @@ class FastAPI(Starlette): callbacks=callbacks, openapi_extra=openapi_extra, generate_unique_id_function=generate_unique_id_function, + form_max_fields=form_max_fields, + form_max_files=form_max_files, + form_max_part_size=form_max_part_size, ) def delete( diff --git a/fastapi/routing.py b/fastapi/routing.py index c10175b16..4e2aeecd1 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -300,6 +300,9 @@ def get_request_handler( response_model_exclude_none: bool = False, dependency_overrides_provider: Optional[Any] = None, embed_body_fields: bool = False, + form_max_fields: int = 1000, + form_max_files: int = 1000, + form_max_part_size: int = 1024 * 1024, ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = dependant.is_coroutine_callable @@ -323,7 +326,11 @@ def get_request_handler( body: Any = None if body_field: if is_body_form: - body = await request.form() + body = await request.form( + max_fields=form_max_fields, + max_files=form_max_files, + max_part_size=form_max_part_size, + ) file_stack.push_async_callback(body.close) else: body_bytes = await request.body() @@ -535,6 +542,9 @@ class APIRoute(routing.Route): generate_unique_id_function: Union[ Callable[["APIRoute"], str], DefaultPlaceholder ] = Default(generate_unique_id), + form_max_fields: int = 1000, + form_max_files: int = 1000, + form_max_part_size: int = 1024 * 1024, ) -> None: self.path = path self.endpoint = endpoint @@ -565,6 +575,9 @@ class APIRoute(routing.Route): self.responses = responses or {} self.name = get_name(endpoint) if name is None else name self.path_regex, self.path_format, self.param_convertors = compile_path(path) + self.form_max_fields = form_max_fields + self.form_max_files = form_max_files + self.form_max_part_size = form_max_part_size if methods is None: methods = ["GET"] self.methods: Set[str] = {method.upper() for method in methods} @@ -661,6 +674,9 @@ class APIRoute(routing.Route): response_model_exclude_none=self.response_model_exclude_none, dependency_overrides_provider=self.dependency_overrides_provider, embed_body_fields=self._embed_body_fields, + form_max_fields=self.form_max_fields, + form_max_files=self.form_max_files, + form_max_part_size=self.form_max_part_size, ) def matches(self, scope: Scope) -> Tuple[Match, Scope]: @@ -989,6 +1005,9 @@ class APIRouter(routing.Router): generate_unique_id_function: Union[ Callable[[APIRoute], str], DefaultPlaceholder ] = Default(generate_unique_id), + form_max_fields: int = 1000, + form_max_files: int = 1000, + form_max_part_size: int = 1024 * 1024, ) -> None: route_class = route_class_override or self.route_class responses = responses or {} @@ -1035,6 +1054,9 @@ class APIRouter(routing.Router): callbacks=current_callbacks, openapi_extra=openapi_extra, generate_unique_id_function=current_generate_unique_id, + form_max_fields=form_max_fields, + form_max_files=form_max_files, + form_max_part_size=form_max_part_size, ) self.routes.append(route) @@ -1067,6 +1089,9 @@ class APIRouter(routing.Router): generate_unique_id_function: Callable[[APIRoute], str] = Default( generate_unique_id ), + form_max_fields: int = 1000, + form_max_files: int = 1000, + form_max_part_size: int = 1024 * 1024, ) -> Callable[[DecoratedCallable], DecoratedCallable]: def decorator(func: DecoratedCallable) -> DecoratedCallable: self.add_api_route( @@ -1095,6 +1120,9 @@ class APIRouter(routing.Router): callbacks=callbacks, openapi_extra=openapi_extra, generate_unique_id_function=generate_unique_id_function, + form_max_fields=form_max_fields, + form_max_files=form_max_files, + form_max_part_size=form_max_part_size, ) return func @@ -2531,6 +2559,39 @@ class APIRouter(routing.Router): """ ), ] = Default(generate_unique_id), + form_max_fields: Annotated[ + int, + Doc( + """ + Maximum number of form fields to accept. + + This limits the number of fields in a form submission to prevent + potential denial-of-service attacks. + """ + ), + ] = 1000, + form_max_files: Annotated[ + int, + Doc( + """ + Maximum number of files to accept in a form submission. + + This limits the number of files in a form submission to prevent + potential denial-of-service attacks. + """ + ), + ] = 1000, + form_max_part_size: Annotated[ + int, + Doc( + """ + Maximum size (in bytes) for each part in a form submission. + + This limits the size of each part in a form submission to prevent + potential denial-of-service attacks. + """ + ), + ] = 1024 * 1024, ) -> Callable[[DecoratedCallable], DecoratedCallable]: """ Add a *path operation* using an HTTP POST operation. @@ -2580,6 +2641,9 @@ class APIRouter(routing.Router): callbacks=callbacks, openapi_extra=openapi_extra, generate_unique_id_function=generate_unique_id_function, + form_max_fields=form_max_fields, + form_max_files=form_max_files, + form_max_part_size=form_max_part_size, ) def delete( From 6b670a9d772550c44e35315c3de44a617b3bbe86 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 3 Dec 2025 22:28:15 +0100 Subject: [PATCH 2/3] Add tests --- tests/test_form_max_fields_files_part_size.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/test_form_max_fields_files_part_size.py diff --git a/tests/test_form_max_fields_files_part_size.py b/tests/test_form_max_fields_files_part_size.py new file mode 100644 index 000000000..4e42263c6 --- /dev/null +++ b/tests/test_form_max_fields_files_part_size.py @@ -0,0 +1,89 @@ +from typing import List + +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient + +app = FastAPI() + + +@app.post("/", form_max_files=2, form_max_part_size=1024, form_max_fields=2) +async def upload_files(files: List[UploadFile] = File(...)): + return {"filenames": [file.filename for file in files]} + + +def test_form_max_files_send_one(): + client = TestClient(app) + + response = client.post( + "/", + files=[ + ("files", ("file1.txt", b"file1 content", "text/plain")), + ], + ) + + assert response.status_code == 200, response.text + assert response.json() == {"filenames": ["file1.txt"]} + + +def test_form_max_files_send_too_many(): + client = TestClient(app) + + response = client.post( + "/", + files=[ + ("files", ("file1.txt", b"file1 content", "text/plain")), + ("files", ("file2.txt", b"file2 content", "text/plain")), + ("files", ("file3.txt", b"file3 content", "text/plain")), + ], + ) + + assert response.status_code == 400, response.text + assert response.json() == { + "detail": "Too many files. Maximum number of files is 2." + } + + +def test_max_part_size_exceeds_custom_limit(): + client = TestClient(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", + } + + response = client.post("/", content=multipart_data, headers=headers) + assert response.status_code == 400 + assert response.text == '{"detail":"Part exceeded maximum size of 1KB."}' + + +def test_form_max_fields_exceeds_limit(): + client = TestClient(app) + + response = client.post( + "/", + files=[("files", ("file1.txt", b"file1 content", "text/plain"))], + data={ + "field1": "value1", + "field2": "value2", + "field3": "value3", + "field4": "value4", + }, + ) + + assert response.status_code == 400, response.text + assert response.json() == { + "detail": "Too many fields. Maximum number of fields is 2." + } From 45b472be8d735d9ad238b46f166efe8a862e594c Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 3 Dec 2025 22:52:36 +0100 Subject: [PATCH 3/3] Rename `form_max_part_size` to `max_part_size` --- fastapi/applications.py | 9 +++----- fastapi/routing.py | 21 ++++++++----------- tests/test_form_max_fields_files_part_size.py | 4 ++-- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index f7f36aff6..da3d4e9f9 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -2646,14 +2646,11 @@ class FastAPI(Starlette): """ ), ] = 1000, - form_max_part_size: Annotated[ + max_part_size: Annotated[ int, Doc( """ - Maximum size (in bytes) for each part in a form submission. - - This limits the size of each part in a form submission to prevent - potential denial-of-service attacks. + Maximum size (in bytes) for each part in a multipart form submission. """ ), ] = 1024 * 1024, @@ -2704,7 +2701,7 @@ class FastAPI(Starlette): generate_unique_id_function=generate_unique_id_function, form_max_fields=form_max_fields, form_max_files=form_max_files, - form_max_part_size=form_max_part_size, + max_part_size=max_part_size, ) def delete( diff --git a/fastapi/routing.py b/fastapi/routing.py index 4e2aeecd1..4e3f65bfd 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -302,7 +302,7 @@ def get_request_handler( embed_body_fields: bool = False, form_max_fields: int = 1000, form_max_files: int = 1000, - form_max_part_size: int = 1024 * 1024, + max_part_size: int = 1024 * 1024, ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = dependant.is_coroutine_callable @@ -329,7 +329,7 @@ def get_request_handler( body = await request.form( max_fields=form_max_fields, max_files=form_max_files, - max_part_size=form_max_part_size, + max_part_size=max_part_size, ) file_stack.push_async_callback(body.close) else: @@ -544,7 +544,7 @@ class APIRoute(routing.Route): ] = Default(generate_unique_id), form_max_fields: int = 1000, form_max_files: int = 1000, - form_max_part_size: int = 1024 * 1024, + max_part_size: int = 1024 * 1024, ) -> None: self.path = path self.endpoint = endpoint @@ -577,7 +577,7 @@ class APIRoute(routing.Route): self.path_regex, self.path_format, self.param_convertors = compile_path(path) self.form_max_fields = form_max_fields self.form_max_files = form_max_files - self.form_max_part_size = form_max_part_size + self.max_part_size = max_part_size if methods is None: methods = ["GET"] self.methods: Set[str] = {method.upper() for method in methods} @@ -676,7 +676,7 @@ class APIRoute(routing.Route): embed_body_fields=self._embed_body_fields, form_max_fields=self.form_max_fields, form_max_files=self.form_max_files, - form_max_part_size=self.form_max_part_size, + max_part_size=self.max_part_size, ) def matches(self, scope: Scope) -> Tuple[Match, Scope]: @@ -1056,7 +1056,7 @@ class APIRouter(routing.Router): generate_unique_id_function=current_generate_unique_id, form_max_fields=form_max_fields, form_max_files=form_max_files, - form_max_part_size=form_max_part_size, + max_part_size=form_max_part_size, ) self.routes.append(route) @@ -2581,14 +2581,11 @@ class APIRouter(routing.Router): """ ), ] = 1000, - form_max_part_size: Annotated[ + max_part_size: Annotated[ int, Doc( """ - Maximum size (in bytes) for each part in a form submission. - - This limits the size of each part in a form submission to prevent - potential denial-of-service attacks. + Maximum size (in bytes) for each part in a multipart form submission. """ ), ] = 1024 * 1024, @@ -2643,7 +2640,7 @@ class APIRouter(routing.Router): generate_unique_id_function=generate_unique_id_function, form_max_fields=form_max_fields, form_max_files=form_max_files, - form_max_part_size=form_max_part_size, + form_max_part_size=max_part_size, ) def delete( diff --git a/tests/test_form_max_fields_files_part_size.py b/tests/test_form_max_fields_files_part_size.py index 4e42263c6..9a5056011 100644 --- a/tests/test_form_max_fields_files_part_size.py +++ b/tests/test_form_max_fields_files_part_size.py @@ -6,7 +6,7 @@ from fastapi.testclient import TestClient app = FastAPI() -@app.post("/", form_max_files=2, form_max_part_size=1024, form_max_fields=2) +@app.post("/", form_max_files=2, max_part_size=1024, form_max_fields=2) async def upload_files(files: List[UploadFile] = File(...)): return {"filenames": [file.filename for file in files]} @@ -54,7 +54,7 @@ def test_max_part_size_exceeds_custom_limit(): "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 + + ("x" * 1024 + "x") # 1KB + 1 byte of data + "\r\n" f"--{boundary}--\r\n" ).encode("utf-8")