Skip to content

Commit d44aa98

Browse files
committed
feat: Optional max_file_size when parsing form
1 parent e116840 commit d44aa98

4 files changed

Lines changed: 56 additions & 3 deletions

File tree

docs/requests.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ async with request.form(max_files=1000, max_fields=1000):
122122
...
123123
```
124124

125+
You can configure maximum size per file uploaded with the parameter `max_file_size`:
126+
127+
```python
128+
async with request.form(max_file_size=100*1024*1024): # 100 MB limit per file
129+
...
130+
```
131+
125132
!!! info
126133
These limits are for security reasons, allowing an unlimited number of fields or files could lead to a denial of service attack by consuming a lot of CPU and memory parsing too many empty fields.
127134

starlette/formparsers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ def __init__(
126126
*,
127127
max_files: int | float = 1000,
128128
max_fields: int | float = 1000,
129+
max_part_file_size: int | float | None = None,
129130
) -> None:
130131
assert multipart is not None, "The `python-multipart` library must be installed to use form parsing."
131132
self.headers = headers
132133
self.stream = stream
133134
self.max_files = max_files
134135
self.max_fields = max_fields
136+
self.max_part_file_size = max_part_file_size
135137
self.items: list[tuple[str, str | UploadFile]] = []
136138
self._current_files = 0
137139
self._current_fields = 0
@@ -247,6 +249,12 @@ async def parse(self) -> FormData:
247249
# the main thread.
248250
for part, data in self._file_parts_to_write:
249251
assert part.file # for type checkers
252+
if (
253+
self.max_part_file_size is not None
254+
and part.file.size is not None
255+
and part.file.size + len(data) > self.max_part_file_size
256+
):
257+
raise MultiPartException(f"File exceeds maximum size of {self.max_part_file_size} bytes.")
250258
await part.file.write(data)
251259
for part in self._file_parts_to_finish:
252260
assert part.file # for type checkers

starlette/requests.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ async def json(self) -> typing.Any:
245245
self._json = json.loads(body)
246246
return self._json
247247

248-
async def _get_form(self, *, max_files: int | float = 1000, max_fields: int | float = 1000) -> FormData:
248+
async def _get_form(
249+
self, *, max_files: int | float = 1000, max_fields: int | float = 1000, max_file_size: int | None
250+
) -> FormData:
249251
if self._form is None:
250252
assert (
251253
parse_options_header is not None
@@ -260,6 +262,7 @@ async def _get_form(self, *, max_files: int | float = 1000, max_fields: int | fl
260262
self.stream(),
261263
max_files=max_files,
262264
max_fields=max_fields,
265+
max_part_file_size=max_file_size,
263266
)
264267
self._form = await multipart_parser.parse()
265268
except MultiPartException as exc:
@@ -274,9 +277,11 @@ async def _get_form(self, *, max_files: int | float = 1000, max_fields: int | fl
274277
return self._form
275278

276279
def form(
277-
self, *, max_files: int | float = 1000, max_fields: int | float = 1000
280+
self, *, max_files: int | float = 1000, max_fields: int | float = 1000, max_file_size: int | None = None
278281
) -> AwaitableOrContextManager[FormData]:
279-
return AwaitableOrContextManagerWrapper(self._get_form(max_files=max_files, max_fields=max_fields))
282+
return AwaitableOrContextManagerWrapper(
283+
self._get_form(max_files=max_files, max_fields=max_fields, max_file_size=max_file_size)
284+
)
280285

281286
async def close(self) -> None:
282287
if self._form is not None: # pragma: no branch

tests/test_formparsers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import typing
55
from contextlib import nullcontext as does_not_raise
66
from pathlib import Path
7+
from secrets import token_bytes
78

89
import pytest
910

@@ -127,6 +128,14 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None:
127128
return app
128129

129130

131+
def make_app_max_file_size(max_file_size: int) -> ASGIApp:
132+
async def app(scope: Scope, receive: Receive, send: Send) -> None:
133+
request = Request(scope, receive)
134+
await request.form(max_file_size=max_file_size)
135+
136+
return app
137+
138+
130139
def test_multipart_request_data(tmpdir: Path, test_client_factory: TestClientFactory) -> None:
131140
client = test_client_factory(app)
132141
response = client.post("/", data={"some": "data"}, files=FORCE_MULTIPART)
@@ -580,6 +589,30 @@ def test_too_many_files_and_fields_raise(
580589
assert res.text == "Too many files. Maximum number of files is 1000."
581590

582591

592+
@pytest.mark.parametrize(
593+
"app,expectation",
594+
[
595+
(make_app_max_file_size(1024), pytest.raises(MultiPartException)),
596+
(Starlette(routes=[Mount("/", app=make_app_max_file_size(1024))]), does_not_raise()),
597+
],
598+
)
599+
def test_max_part_file_size_raise(
600+
tmpdir: Path,
601+
app: ASGIApp,
602+
expectation: typing.ContextManager[Exception],
603+
test_client_factory: TestClientFactory,
604+
) -> None:
605+
path = os.path.join(tmpdir, "test.txt")
606+
with open(path, "wb") as file:
607+
file.write(token_bytes(1024 + 1))
608+
609+
client = test_client_factory(app)
610+
with open(path, "rb") as f, expectation:
611+
response = client.post("/", files={"test": f})
612+
assert response.status_code == 400
613+
assert response.text == "File exceeds maximum size of 1024 bytes."
614+
615+
583616
@pytest.mark.parametrize(
584617
"app,expectation",
585618
[

0 commit comments

Comments
 (0)