Skip to content

Commit b701591

Browse files
committed
fix: enforce server-side upload size and file count limits
The upload handler reads entire files into memory with no size limit. The frontend Upload component has max_size/max_files props, but these are client-side only and trivially bypassed via direct HTTP requests. Add server-side enforcement with three defense layers: - Content-Length pre-check before Starlette buffers the body - Per-file file.size header check (no read needed) - Chunked read with byte counting as fallback Also adds upload_max_files to limit file count per request. Both limits are configurable via rx.Config or env vars (REFLEX_UPLOAD_MAX_SIZE, REFLEX_UPLOAD_MAX_FILES) and default to 10 MB / 10 files. Set to 0 to disable. Closes #6115
1 parent 5d1d6db commit b701591

File tree

3 files changed

+387
-10
lines changed

3 files changed

+387
-10
lines changed

reflex/app.py

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,16 +1911,47 @@ async def upload_file(request: Request):
19111911
"""
19121912
from reflex.utils.exceptions import UploadTypeError, UploadValueError
19131913

1914+
config = get_config()
1915+
upload_max_size = config.upload_max_size
1916+
upload_max_files = config.upload_max_files
1917+
1918+
# Reject based on Content-Length before Starlette buffers the body.
1919+
# Content-Length covers the entire multipart request, so use
1920+
# (per-file limit * max files) as the upper bound.
1921+
if upload_max_size > 0 and upload_max_files > 0:
1922+
content_length_str = request.headers.get("content-length")
1923+
if content_length_str is not None:
1924+
try:
1925+
content_length = int(content_length_str)
1926+
except ValueError:
1927+
return JSONResponse(
1928+
status_code=400,
1929+
content={"detail": "Invalid Content-Length header."},
1930+
)
1931+
max_request_size = upload_max_size * upload_max_files
1932+
if content_length > max_request_size:
1933+
return JSONResponse(
1934+
status_code=413,
1935+
content={"detail": f"Upload exceeds the maximum allowed size of {max_request_size} bytes."},
1936+
)
1937+
19141938
# Get the files from the request.
19151939
try:
19161940
files = await request.form()
19171941
except ClientDisconnect:
19181942
return Response() # user cancelled
1919-
files = files.getlist("files")
1920-
if not files:
1943+
file_list = files.getlist("files")
1944+
if not file_list:
19211945
msg = "No files were uploaded."
19221946
raise UploadValueError(msg)
19231947

1948+
# Enforce max file count.
1949+
if upload_max_files > 0 and len(file_list) > upload_max_files:
1950+
return JSONResponse(
1951+
status_code=400,
1952+
content={"detail": f"Too many files uploaded ({len(file_list)}). Maximum allowed is {upload_max_files}."},
1953+
)
1954+
19241955
token = request.headers.get("reflex-client-token")
19251956
handler = request.headers.get("reflex-event-handler")
19261957

@@ -1966,19 +1997,54 @@ async def upload_file(request: Request):
19661997
)
19671998
raise UploadValueError(msg)
19681999

2000+
async def _cleanup(upload_files, copied_files):
2001+
"""Close all uploaded files and any already-copied BytesIO buffers.
2002+
2003+
Args:
2004+
upload_files: The raw uploaded file list from the request.
2005+
copied_files: The list of UploadFile copies made so far.
2006+
"""
2007+
for f in upload_files:
2008+
if isinstance(f, StarletteUploadFile):
2009+
await f.close()
2010+
for f in copied_files:
2011+
f.file.close()
2012+
19692013
# Make a copy of the files as they are closed after the request.
19702014
# This behaviour changed from fastapi 0.103.0 to 0.103.1 as the
19712015
# AsyncExitStack was removed from the request scope and is now
19722016
# part of the routing function which closes this before the
19732017
# event is handled.
19742018
file_copies = []
1975-
for file in files:
2019+
for file in file_list:
19762020
if not isinstance(file, StarletteUploadFile):
2021+
await _cleanup(file_list, file_copies)
19772022
raise UploadValueError(
19782023
"Uploaded file is not an UploadFile." + str(file)
19792024
)
2025+
# Enforce upload size limit: early rejection via file.size header
2026+
if upload_max_size > 0 and file.size is not None and file.size > upload_max_size:
2027+
await _cleanup(file_list, file_copies)
2028+
return JSONResponse(
2029+
status_code=413,
2030+
content={"detail": f"File exceeds the maximum upload size of {upload_max_size} bytes."},
2031+
)
19802032
content_copy = io.BytesIO()
1981-
content_copy.write(await file.read())
2033+
# Read in chunks to enforce limit even when file.size is not reported
2034+
bytes_read = 0
2035+
while True:
2036+
chunk = await file.read(1024 * 1024) # 1 MB chunks
2037+
if not chunk:
2038+
break
2039+
bytes_read += len(chunk)
2040+
if upload_max_size > 0 and bytes_read > upload_max_size:
2041+
content_copy.close()
2042+
await _cleanup(file_list, file_copies)
2043+
return JSONResponse(
2044+
status_code=413,
2045+
content={"detail": f"File exceeds the maximum upload size of {upload_max_size} bytes."},
2046+
)
2047+
content_copy.write(chunk)
19822048
content_copy.seek(0)
19832049
file_copies.append(
19842050
UploadFile(
@@ -1989,12 +2055,10 @@ async def upload_file(request: Request):
19892055
)
19902056
)
19912057

1992-
for file in files:
1993-
if not isinstance(file, StarletteUploadFile):
1994-
raise UploadValueError(
1995-
"Uploaded file is not an UploadFile." + str(file)
1996-
)
1997-
await file.close()
2058+
# Close the raw uploaded files (copies are kept for event processing).
2059+
for file in file_list:
2060+
if isinstance(file, StarletteUploadFile):
2061+
await file.close()
19982062

19992063
event = Event(
20002064
token=token,

reflex/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,13 @@ class BaseConfig:
260260
# The transport method for client-server communication.
261261
transport: Literal["websocket", "polling"] = "websocket"
262262

263+
# Maximum file upload size in bytes. Files larger than this will be rejected with HTTP 413.
264+
# Defaults to 10 MB. Set to 0 to disable the limit.
265+
upload_max_size: int = 10 * 1024 * 1024 # 10 MB
266+
267+
# Maximum number of files per upload request. Set to 0 to disable the limit.
268+
upload_max_files: int = 10
269+
263270
# Whether to skip plugin checks.
264271
_skip_plugins_checks: bool = dataclasses.field(default=False, repr=False)
265272

@@ -369,6 +376,13 @@ def _post_init(self, **kwargs):
369376
msg = f"{self._prefixes[0]}REDIS_URL is required when using the redis state manager."
370377
raise ConfigError(msg)
371378

379+
if self.upload_max_size < 0:
380+
msg = "upload_max_size must be >= 0."
381+
raise ConfigError(msg)
382+
if self.upload_max_files < 0:
383+
msg = "upload_max_files must be >= 0."
384+
raise ConfigError(msg)
385+
372386
def _add_builtin_plugins(self):
373387
"""Add the builtin plugins to the config."""
374388
for plugin in _PLUGINS_ENABLED_BY_DEFAULT:

0 commit comments

Comments
 (0)