Skip to content

Conversation

@tsaipraveen99
Copy link

Problem

The upload handler in reflex/app.py reads entire uploaded files into memory
(io.BytesIO) 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. 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:

Config field Env var Default Description
upload_max_size REFLEX_UPLOAD_MAX_SIZE 10485760 (10 MB) Per-file size limit in bytes. 0 disables.
upload_max_files REFLEX_UPLOAD_MAX_FILES 10 Max files per request. 0 disables.

Layer 1 — Content-Length pre-check. Before Starlette parses the multipart
body, the request's Content-Length header is compared against
upload_max_size × upload_max_files. Oversized requests are rejected
immediately with HTTP 413. Malformed Content-Length values return HTTP 400.

Layer 2 — Per-file file.size header check. After form parsing, each
file's reported size is checked against upload_max_size. Files that declare a
size 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, the
read is aborted. This catches files that omitted or spoofed their size header.

Files changed

  • reflex/config.py — Add upload_max_size and upload_max_files to
    BaseConfig. Add validation in Config._post_init (after update_from_env
    applies overrides, so invalid env values like REFLEX_UPLOAD_MAX_SIZE=-1 are
    caught).
  • reflex/app.py — Add all three enforcement layers plus file count check
    to upload_file(). A _cleanup() helper ensures all file handles and
    already-copied BytesIO buffers are closed on every rejection path. Error
    messages do not include the attacker-controlled filename.
  • tests/units/test_upload_size_limit.py (new) — 14 tests covering
    config 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:

# rxconfig.py
config = rx.Config(
    app_name="myapp",
    upload_max_size=50 * 1024 * 1024,  # 50 MB
    upload_max_files=25,
)

Or via environment variables:

REFLEX_UPLOAD_MAX_SIZE=0 reflex run  # disable size limit entirely

Design decisions

  • HTTP 413 (Payload Too Large) for size violations, HTTP 400 (Bad
    Request) for file count violations — correct HTTP semantics for each case.
  • 0 disables the limit rather than a separate boolean — follows the
    convention used by nginx (client_max_body_size 0), Apache, and others.
  • Filename omitted from error messagesfile.filename is
    attacker-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 the
    file_list variable — avoids a fragile closure dependency and also closes
    BytesIO buffers from already-copied files when a later file triggers
    rejection.

Test plan

  • New tests: pytest tests/units/test_upload_size_limit.py — 14 passed
  • Existing upload handler tests: pytest tests/units/test_app.py -k upload — 9 passed
  • Existing upload component tests: pytest tests/units/components/core/test_upload.py — 6 passed
  • Config tests: pytest tests/units/test_config.py — 31 passed

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 8, 2026

Greptile Overview

Greptile Summary

This PR adds server-side enforcement for file uploads in reflex.app.upload(): a request-level Content-Length pre-check, a per-file max-size check (via file.size when available), and a chunked read with byte-counting to stop oversized uploads even when the size header is missing/spoofed. It also adds new config fields (upload_max_size, upload_max_files) with non-negative validation in Config._post_init, plus unit tests covering the new behaviors.

Main issue to address before merge: the new unit tests’ request mock always stringifies the provided content-length value, making it hard to accurately model certain malformed header cases (e.g. empty string/whitespace) and potentially leaving gaps in coverage for the 400-path. Tightening the mock to accept raw header values would improve correctness of these tests.

Confidence Score: 4/5

  • This PR is generally safe to merge after addressing a small but important unit-test coverage gap around malformed Content-Length handling.
  • Core changes are localized to upload handling and config validation, and the new enforcement paths are clear. The remaining concern is that the new tests’ request mock may not accurately represent malformed header inputs, which could hide regressions in the Content-Length pre-check behavior.
  • tests/units/test_upload_size_limit.py

Important Files Changed

Filename Overview
reflex/app.py Adds server-side upload Content-Length precheck, per-file size and count enforcement, and chunked reads with cleanup; need to verify error handling paths don't leak open file handles and that new limits are enforced without breaking existing handler flow.
reflex/config.py Introduces upload_max_size/upload_max_files config defaults and validates non-negative values in post-init; appears straightforward and consistent with other config validation.
tests/units/test_upload_size_limit.py Adds tests for new upload limits, but helper _make_request_mock casts content-length to str, which makes the malformed Content-Length test ineffective and can lead to false confidence.

Sequence Diagram

sequenceDiagram
    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)

Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a 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

Edit Code Review Agent Settings | Greptile

Comment on lines 26 to 35
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
Copy link
Contributor

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.

Copy link
Author

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Denial of Service (DoS) via Memory Exhaustion in File Upload Handling

1 participant