-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix: enforce server-side upload size and file count limits #6119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Greptile OverviewGreptile SummaryThis PR adds server-side enforcement for file uploads in Main issue to address before merge: the new unit tests’ request mock always stringifies the provided Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant UploadHandler as reflex.app.upload_file
participant Config as reflex.config.get_config
participant Starlette as Request.form()
participant StateMgr as app.state_manager
Client->>UploadHandler: POST /upload (multipart)
UploadHandler->>Config: get_config()
Config-->>UploadHandler: upload_max_size, upload_max_files
alt Content-Length present & limits enabled
UploadHandler->>UploadHandler: parse int(Content-Length)
alt malformed Content-Length
UploadHandler-->>Client: 400 JSON {detail}
else exceeds max_size*max_files
UploadHandler-->>Client: 413 JSON {detail}
end
end
UploadHandler->>Starlette: await request.form()
Starlette-->>UploadHandler: FormData
UploadHandler->>UploadHandler: file_list = form.getlist("files")
alt no files
UploadHandler-->>Client: UploadValueError (handled upstream)
else too many files (limit enabled)
UploadHandler-->>Client: 400 JSON {detail}
end
UploadHandler->>StateMgr: get_state(substate_token)
StateMgr-->>UploadHandler: State
loop for each uploaded file
alt not UploadFile
UploadHandler->>UploadHandler: cleanup(open uploads + copied buffers)
UploadHandler-->>Client: UploadValueError
else file.size header > upload_max_size
UploadHandler->>UploadHandler: cleanup(open uploads + copied buffers)
UploadHandler-->>Client: 413 JSON {detail}
else chunked read
UploadHandler->>UploadHandler: read 1MB chunks, count bytes
alt bytes_read > upload_max_size
UploadHandler->>UploadHandler: close BytesIO + cleanup
UploadHandler-->>Client: 413 JSON {detail}
else copy to BytesIO
UploadHandler->>UploadHandler: append rx.UploadFile copy
end
end
end
UploadHandler->>UploadHandler: close raw uploaded files
UploadHandler-->>Client: StreamingResponse (ndjson updates)
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3 files reviewed, 1 comment
| def _make_request_mock(files, content_length=None): | ||
| """Create a mock Starlette Request with the given files.""" | ||
| request_mock = unittest.mock.Mock() | ||
| headers = { | ||
| "reflex-client-token": "test-token", | ||
| "reflex-event-handler": "fake_state.handle_upload", | ||
| } | ||
| if content_length is not None: | ||
| headers["content-length"] = str(content_length) | ||
| request_mock.headers = headers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Malformed Content-Length test gap
_make_request_mock always sets headers["content-length"] = str(content_length), which means the tests can’t represent some real malformed/edge header cases (e.g. empty string/whitespace) and may give false confidence in the 400-path. Consider letting callers pass the raw header value through without str() so you can explicitly test "", " ", etc. and ensure int() handling matches the intended behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — fixed in the latest push.
_make_request_mock now uses a sentinel default and only calls str() on int inputs. Raw string values (including "", " ", etc.) are passed through directly to the header dict, so the mock accurately represents what Starlette would surface.
The malformed Content-Length test is now parametrized across five edge cases:
| Input | int() behavior |
Expected |
|---|---|---|
"not-a-number" |
ValueError |
400 |
"" |
ValueError |
400 |
" " |
ValueError |
400 |
"12.5" |
ValueError |
400 |
"1e3" |
ValueError |
400 |
All 18 tests pass (up from 14 — the parametrized test adds 4 new cases).
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 reflex-dev#6115
41aed31 to
b701591
Compare
Problem
The upload handler in
reflex/app.pyreads entire uploaded files into memory(
io.BytesIO) with no size limit. The frontendUploadcomponent hasmax_size/max_filesprops, but these are client-side only and triviallybypassed via direct HTTP requests. An attacker can send arbitrarily large or
numerous files to exhaust server memory and crash the process.
Closes #6115
Solution
Add server-side enforcement with three layers of defense, controlled by two new
config fields:
upload_max_sizeREFLEX_UPLOAD_MAX_SIZE10485760(10 MB)0disables.upload_max_filesREFLEX_UPLOAD_MAX_FILES100disables.Layer 1 — Content-Length pre-check. Before Starlette parses the multipart
body, the request's
Content-Lengthheader is compared againstupload_max_size × upload_max_files. Oversized requests are rejectedimmediately with HTTP 413. Malformed
Content-Lengthvalues return HTTP 400.Layer 2 — Per-file
file.sizeheader check. After form parsing, eachfile's reported size is checked against
upload_max_size. Files that declare asize over the limit are rejected without reading any bytes.
Layer 3 — Chunked read with byte counting. File content is streamed in 1 MB
chunks during the copy loop. If accumulated bytes exceed
upload_max_size, theread is aborted. This catches files that omitted or spoofed their size header.
Files changed
reflex/config.py— Addupload_max_sizeandupload_max_filestoBaseConfig. Add validation inConfig._post_init(afterupdate_from_envapplies overrides, so invalid env values like
REFLEX_UPLOAD_MAX_SIZE=-1arecaught).
reflex/app.py— Add all three enforcement layers plus file count checkto
upload_file(). A_cleanup()helper ensures all file handles andalready-copied
BytesIObuffers are closed on every rejection path. Errormessages do not include the attacker-controlled filename.
tests/units/test_upload_size_limit.py(new) — 14 tests coveringconfig defaults, negative value rejection, Content-Length handling
(over/under/malformed), per-file rejection (via size header and chunked read),
file count enforcement, disabled-limit behavior, and filename sanitization.
All tests invoke the actual
upload()handler with mocked requests.Migration note
Existing apps that accept uploads larger than 10 MB or more than 10 files per
request will need to adjust their config:
Or via environment variables:
REFLEX_UPLOAD_MAX_SIZE=0 reflex run # disable size limit entirelyDesign decisions
Request) for file count violations — correct HTTP semantics for each case.
0disables the limit rather than a separate boolean — follows theconvention used by nginx (
client_max_body_size 0), Apache, and others.file.filenameisattacker-controlled input; echoing it in the response risks reflected XSS if
the JSON is ever rendered as HTML downstream.
_cleanup()takes explicit parameters rather than closing over thefile_listvariable — avoids a fragile closure dependency and also closesBytesIObuffers from already-copied files when a later file triggersrejection.
Test plan
pytest tests/units/test_upload_size_limit.py— 14 passedpytest tests/units/test_app.py -k upload— 9 passedpytest tests/units/components/core/test_upload.py— 6 passedpytest tests/units/test_config.py— 31 passed