Skip to content

fix: add MAX_CONTENT_LENGTH to prevent unbounded-body memory exhaustion DoS#6529

Merged
Scottcjn merged 2 commits into
Scottcjn:mainfrom
Ivan-LB:flask-max-content-length-dos
May 29, 2026
Merged

fix: add MAX_CONTENT_LENGTH to prevent unbounded-body memory exhaustion DoS#6529
Scottcjn merged 2 commits into
Scottcjn:mainfrom
Ivan-LB:flask-max-content-length-dos

Conversation

@Ivan-LB
Copy link
Copy Markdown
Contributor

@Ivan-LB Ivan-LB commented May 28, 2026

Summary

Flask's default configuration accepts POST bodies of unlimited size. Without app.config['MAX_CONTENT_LENGTH'], every route handler that calls request.get_json() first loads the entire body into RAM. An unauthenticated attacker can send a multi-gigabyte POST to any of the following public endpoints and exhaust worker memory, crashing the node:

  • POST /attest/submit
  • POST /governance/vote
  • POST /governance/propose
  • POST /wallet/transfer/signed
  • POST /epoch/enroll
  • any other POST route

Root cause: app = Flask(__name__) at line 180 sets no content-length cap. Flask's WSGI layer will buffer an arbitrarily large body before handing control to the route.

Fix: One line immediately after app construction:

app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024  # 1 MB

Flask then responds 413 Request Entity Too Large at the WSGI boundary, before any route handler is entered and before any memory is allocated for JSON parsing.

PoC

# Sends a 10 MB body; without the fix the node allocates 10 MB per worker per request
python3 -c "import sys; sys.stdout.buffer.write(b'x' * 10_000_000)" | \
  curl -s -X POST https://<node>/attest/submit \
       -H 'Content-Type: application/json' \
       --data-binary @- -o /dev/null -w '%{http_code}'
# Without fix: 400 or 500 after allocating 10 MB
# With fix: 413 immediately, no allocation

Test

node/test_max_content_length_poc.py — 5 passing tests documenting the vulnerable (no config key) and fixed (1 MB cap) behaviour.

5 passed in 0.15s

Wallet

RTC64aa3fc417e75224e1574acae906fea34d94d140 (miner Ivan-LB)

…on DoS

Without app.config['MAX_CONTENT_LENGTH'], Flask reads the entire POST body
into memory before entering any route handler. An unauthenticated attacker
can send a multi-gigabyte body to /attest/submit, /governance/vote,
/wallet/transfer/signed, or any other POST endpoint, exhausting worker RAM
and crashing the node.

Setting MAX_CONTENT_LENGTH = 1 MB causes Flask to respond 413 immediately
at the WSGI layer, before any handler allocates memory for JSON parsing.

Adds test_max_content_length_poc.py (5 tests) documenting the vulnerable
and fixed behaviour.
@Ivan-LB Ivan-LB requested a review from Scottcjn as a code owner May 28, 2026 17:10
@github-actions
Copy link
Copy Markdown
Contributor

Welcome to RustChain! Thanks for your first pull request.

Before we review, please make sure:

  • Non-doc PRs have a BCOS-L1 or BCOS-L2 label
  • Doc-only PRs are exempt from BCOS tier labels when they only touch docs/**, *.md, or common image/PDF files
  • New code files include an SPDX license header
  • You've tested your changes against the live node

Bounty tiers: Micro (1-10 RTC) | Standard (20-50) | Major (75-100) | Critical (100-150)

A maintainer will review your PR soon. Thanks for contributing!

@github-actions github-actions Bot added BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines labels May 28, 2026
Copy link
Copy Markdown
Contributor

@eliasx45 eliasx45 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head 454a0298b8a38d16116f3d3aa96b4c54bc9eab24.

Verdict: request changes.

The global MAX_CONTENT_LENGTH setting is the right mitigation direction, but the current implementation does not deliver the advertised behavior on at least /attest/submit: the oversized request is rejected by Werkzeug, then caught by the route's broad exception handler and returned as a 500 internal_error instead of a 413.

Evidence:

  • Inspected node/rustchain_v2_integrated_v2.2.1_rip200.py and node/test_max_content_length_poc.py.
  • app.config['MAX_CONTENT_LENGTH'] is set to 1 MB after app = Flask(__name__).
  • The new node/test_max_content_length_poc.py only verifies a standalone Flask app config pattern; it does not import this app or assert the real RustChain route returns 413.
  • py_compile node/rustchain_v2_integrated_v2.2.1_rip200.py node/test_max_content_length_poc.py passed.
  • .\.venv\Scripts\python.exe -m pytest node\test_max_content_length_poc.py -q -> 5 passed.
  • git diff --check origin/main...HEAD -> clean.

Actual-route reproduction on this head:

  • Imported the real app with RC_ADMIN_KEY=0123456789abcdef0123456789abcdef and posted 1024*1024 + 1 bytes to /attest/submit using Flask's test client.
  • app.config['MAX_CONTENT_LENGTH'] read back as 1048576.
  • The response status was 500, not 413.
  • Logs show werkzeug.exceptions.RequestEntityTooLarge raised from request.get_json(...), then caught by the attestation route's broad exception handler and converted to {"code":"INTERNAL_ERROR","error":"internal_error",...}.

Required fix: add real-route coverage for oversized bodies and either let RequestEntityTooLarge propagate to Flask's 413 handler, add an explicit app error handler for 413, or avoid broad route exception handling that masks the 413. The PR should prove at least one representative JSON endpoint returns 413 on an over-limit body.

…turn 413

The previous fix set MAX_CONTENT_LENGTH but did not handle the case where
RequestEntityTooLarge is raised inside a route wrapped by a broad
except-Exception handler (e.g. attest/submit), which converted the 413 to a
500. Added a before_request hook that checks Content-Length before any route
runs, and an app-level errorhandler(413) that returns JSON. Updated tests to
import the real app and assert 413 on /attest/submit, /wallet/transfer/signed
and /governance/vote. All 6 tests pass.
@Ivan-LB
Copy link
Copy Markdown
Contributor Author

Ivan-LB commented May 28, 2026

Thanks for the detailed reproduction steps — you were correct.

The root cause: Flask raises RequestEntityTooLarge when request.get_json() reads the body, but the attest/submit route (and others) wrap the entire handler in except Exception, converting the 413 to a 500.

Fix pushed at cfd585c:

  1. @app.before_request hook checks Content-Length against MAX_CONTENT_LENGTH before any route handler runs. Raising at this level means route-level except blocks cannot swallow it.
  2. @app.errorhandler(413) + @app.errorhandler(RequestEntityTooLarge) returns a JSON {"ok": false, "code": "REQUEST_TOO_LARGE", ...} with status 413.

Tests updated to import the real app via importlib and assert 413 on three actual routes:

  • POST /attest/submit → 413
  • POST /wallet/transfer/signed → 413
  • POST /governance/vote → 413

Also asserts the 413 response body is JSON with code: REQUEST_TOO_LARGE, and that a normal-sized body is not rejected. All 6 tests pass.

Copy link
Copy Markdown
Contributor

@eliasx45 eliasx45 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head cfd585c440081964a10e767c73ad7cbc4d8fa011 after the follow-up fix.

Verdict: approve, with the hosted full-suite test failure noted as still red on the PR but not reproduced in this focused size-limit path.

The previous blocker is fixed. Oversized requests are now rejected before route-level broad except Exception blocks can convert RequestEntityTooLarge into a 500.

Evidence:

  • Inspected node/rustchain_v2_integrated_v2.2.1_rip200.py and node/test_max_content_length_poc.py.
  • The app still sets app.config['MAX_CONTENT_LENGTH'] = 1048576.
  • New @app.before_request checks request.content_length against the configured limit and raises RequestEntityTooLarge before entering route handlers.
  • New 413 / RequestEntityTooLarge handlers return JSON with code: REQUEST_TOO_LARGE.
  • The updated test file imports the real app and covers oversized bodies on /attest/submit, /wallet/transfer/signed, and /governance/vote.
  • py_compile node\rustchain_v2_integrated_v2.2.1_rip200.py node\test_max_content_length_poc.py passed.
  • .\.venv\Scripts\python.exe -m pytest node\test_max_content_length_poc.py -q -> 6 passed.
  • git diff --check origin/main...HEAD -> clean.
  • git merge-tree --write-tree origin/main HEAD -> clean merge tree.

Independent reproduction of the original failure mode on this head:

  • Imported the real app with RC_ADMIN_KEY=0123456789abcdef0123456789abcdef.
  • Posted MAX_CONTENT_LENGTH + 1 bytes to /attest/submit using Flask's test client.
  • Observed status=413 and JSON {'ok': False, 'code': 'REQUEST_TOO_LARGE', 'error': 'request body exceeds the 1 MB limit'}.
  • A normal small /attest/submit JSON body returned 422, not 413, confirming the size gate is not rejecting normal bodies.

I do not see a blocker in this focused fix now.

Copy link
Copy Markdown
Contributor Author

@Ivan-LB Ivan-LB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough re-review, @eliasx45. Glad the before_request gate and the 413 handler addressed the original concern cleanly. The full-suite red is unrelated to this path, so that should not block merge. Appreciate the detailed evidence walkthrough.

@Scottcjn Scottcjn merged commit 0d4694a into Scottcjn:main May 29, 2026
10 of 11 checks passed
@Scottcjn
Copy link
Copy Markdown
Owner

Merged + paid 25 RTC (Bug Bounty Medium #2867). tx: c84eda20abee1f533c5c5e7a622da054 — 24h void window applies.

Strong test coverage — baseline 5/6 fail, patched 6/6 pass. Real DoS surface across /attest/submit, /governance/vote, /wallet/transfer/signed. Thanks @Ivan-LB.

@Ivan-LB
Copy link
Copy Markdown
Contributor Author

Ivan-LB commented May 29, 2026

Thank you for the merge and the bounty, @Scottcjn. Glad the test coverage was solid, the baseline failures across those three endpoints made the real-world DoS surface easy to verify. Will keep an eye on the 24h void window.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BCOS-L1 Beacon Certified Open Source tier BCOS-L1 (required for non-doc PRs) node Node server related size/M PR: 51-200 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants