[UTXO-BUG] /wallet/history: unbounded SQL fetchall causes OOM DoS#6562
Conversation
|
Welcome to RustChain! Thanks for your first pull request. Before we review, please make sure:
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! |
eliasx45
left a comment
There was a problem hiding this comment.
Reviewed current head 5573b21f35abecdffcc85e9770e6003213fa26ae.
Verdict: request changes.
The PR adds SQL LIMIT ? to the three /wallet/history inner queries, but the current implementation still has a high-offset OOM path and the new tests fail on Windows.
Primary issue: _history_cap = offset + limit is not bounded. Since offset is currently only clamped to >= 0, an unauthenticated caller can request something like GET /wallet/history?miner_id=<wallet>&limit=1&offset=1000000, and each inner query is allowed to read up to 1,000,001 rows into Python memory before the final slice. That preserves the core unbounded-memory risk for large offsets. Either use true SQL LIMIT ? OFFSET ? per source, cap offset, or otherwise bound the maximum rows fetched independent of caller offset.
Validation/repro:
git diff --check origin/main...HEAD
# clean
git merge-tree --write-tree origin/main HEAD
# ac3d121fdfa6b96108701039f25de81c1e3df74b
python -m py_compile node/rustchain_v2_integrated_v2.2.1_rip200.py node/test_wallet_history_oom_poc.py
# passed
python -m pytest -q node/test_wallet_history_oom_poc.py --tb=short
# FAILED: 3 failed, 4 passed
# UnicodeDecodeError: 'charmap' codec can't decode byte 0x90 ... while open(source_path).read()
python tools/bcos_spdx_check.py --base-ref origin/main
# FAILED: node/test_wallet_history_oom_poc.py is missing an SPDX header
The pytest failures come from _extract_history_queries() reading the integrated module without encoding="utf-8"; on this Windows checkout the default cp1252 codec cannot decode the file. Please make the regression portable, add the required SPDX header, and close the high-offset fetch path before this is ready.
…f-8 encoding and SPDX header to test
|
Thanks for the detailed review. Three changes in the new commit: High-offset OOM path closed. The Test portability. SPDX header. Added |
eliasx45
left a comment
There was a problem hiding this comment.
Re-reviewed current head 98f54395650a2ffdd73427e47a3b93c02e068e13 after the follow-up commit. Verdict: approve.
The prior high-offset OOM path is now bounded: limit is capped at 200, offset is capped at 9800, and the inner SQL queries use _history_cap = offset + limit, so each source query is bounded to at most 10000 rows regardless of caller-supplied offset. The Windows portability and SPDX blockers are also fixed.
Validation on this head:
git diff --check origin/main...HEAD
# clean
git merge-tree --write-tree origin/main HEAD
# 9d7599fac6a5c53a4d9f0e576842540f4a2f10ef
python -m py_compile node/rustchain_v2_integrated_v2.2.1_rip200.py node/test_wallet_history_oom_poc.py
# passed
python -m pytest -q node/test_wallet_history_oom_poc.py --tb=short
# 8 passed
python tools/bcos_spdx_check.py --base-ref origin/main
# BCOS SPDX check: OK
Scope note: this bounds the single-request /wallet/history memory path. It does not make wallet history private, but that is separate from the OOM fetchall fix reviewed here.
|
Thanks for re-reviewing the head and running the full validation suite. Good to know the |
|
Updated the test file to address the feedback from the previous review. Section B now imports the real Flask app via importlib and calls GET /wallet/history through app.test_client() directly. The stub _build_app() and the four standalone-SQL tests have been replaced with TestWalletHistoryFlaskRoute, a unittest.TestCase class with 7 tests that exercise the real route: ok field, default limit bound, explicit limit enforcement, large-offset capping (999999 → 9800 silently), missing miner_id returning 400, non-integer limit returning 400, non-integer offset returning 400. Section A source-scan tests are unchanged. All 11 tests pass locally. |
eliasx45
left a comment
There was a problem hiding this comment.
Re-reviewed current head 9277e4123a7d7f60a53b7b422ca6d5489a5472b7 after the test rewrite.
Verdict: approve.
The new Section B coverage is a material improvement over the stubbed route tests: it imports the real integrated Flask app and exercises GET /wallet/history through app.test_client(). The regression now covers the real route returning ok: true, default limit behavior, explicit limit enforcement, large-offset capping, missing miner_id, non-integer limit, and non-integer offset. The source-scan Section A still verifies the SQL LIMIT / _history_cap / offset-cap structure.
Validation on this Windows checkout:
git diff --check origin/main...HEAD
# clean
git merge-tree --write-tree origin/main HEAD
# 24781afc2e36ccc47ec7126f13266f3bf9b18a64
..\Rustchain\.venv\Scripts\python.exe -m py_compile node\rustchain_v2_integrated_v2.2.1_rip200.py node\test_wallet_history_oom_poc.py
# passed
PYTHONPATH=node ..\Rustchain\.venv\Scripts\python.exe -m pytest -q node\test_wallet_history_oom_poc.py --tb=short
# 11 passed
..\Rustchain\.venv\Scripts\python.exe tools\bcos_spdx_check.py --base-ref origin/main
# BCOS SPDX check: OK
The reviewed scope remains the single-request /wallet/history OOM bound; privacy/enumeration concerns are separate from this route-memory fix.
|
Thanks for re-reviewing after the test rewrite and validating on Windows. Glad the Section B coverage landed well — the integrated Flask client tests against the real route were the right call over the stubbed approach. Appreciate the thorough sign-off. |
jaxint
left a comment
There was a problem hiding this comment.
LGTM! Great work on this PR. 🚀
crystal-tensor
left a comment
There was a problem hiding this comment.
LGTM! Code review approved by @cx95zz (QClaw automated review agent).
Reviewed for: correctness, security, test coverage, and code quality.
No issues found - APPROVED.
Introduces three foundation pieces to eliminate the recurring UTXO-OOM bug class (4 [UTXO-BUG] fixes shipped this week — #6526, #6535, #6537, #6562, #6563, #6571 — all the same .fetchall() shape): 1. node/db_helpers.py (190 LOC): - fetch_page(conn, sql, params, *, limit, offset=0, max_limit=1000) - Always appends LIMIT/OFFSET before issuing SELECT - Rejects sql already containing LIMIT (case-insensitive) - Rejects limit > max_limit or negative limit/offset - fetch_one_or_none(conn, sql, params) - For queries that MUST return 0 or 1 row - Raises if >1 row materializes - count_estimate(conn, table, *, where=None, params=()) 2. tests/test_db_helpers.py (208 LOC, 23 tests): - Happy path, edge cases, limit enforcement - SQL-already-has-LIMIT rejection (upper/lower/mixed case) - offset behavior, semicolon handling, zero-limit, etc. - All 23 pass against in-memory sqlite 3. scripts/check_fetchall.sh (117 LOC): - CI guard greps node/ for .fetchall() outside tests/deprecated - For each hit: checks same-line or prior-line opt-in annotation # fetchall-ok: <reason> where reason in: bounded-by-schema, pragma-result, internal-test-helper, already-paginated - Currently informational (will be wired into GH Actions in Part B) What this PR does NOT do (left intentionally claimable for #6627 bounty): - Site conversion of the ~50 .fetchall() instances in node/rustchain_v2_integrated_v2.2.1_rip200.py - Annotation sweep on the ~175 legit sites across other modules - GH Actions wire-in (.github/workflows/check_fetchall.yml) - Part B (25 RTC): CI guard wire + annotation sweep - Part A2 (25 RTC, if claimed separately): main-file conversion Scott as author, NOT claiming the bounty — this is operator foundation work so contributors can claim the larger sweep against a stable helper. Closes: foundation portion of #6627 Refs: #6526, #6535, #6537, #6562, #6563, #6571 (already-merged instances of the class) Co-authored-by: Scott Boudreaux <scottbphone12@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug
/wallet/history accepts limit and offset query parameters but the three inner SQL queries in api_wallet_history() had no SQL LIMIT clause. All matching rows were loaded into Python memory before pagination was applied in Python.
The ledger, epoch_rewards, and pending_ledger subqueries each call .fetchall() with no LIMIT, loading the wallet's entire history into RAM. The Python slice at the end only slices the already-loaded list and never prevents the database from scanning unbounded rows.
Exploit scenario
An attacker sends:
GET /wallet/history?miner_id=&limit=1&offset=0
The node responds with one row, but internally loads every ledger entry, every reward epoch, and every pending transfer for that wallet into RAM. On a mature node with hundreds of thousands of reward epochs, this can exhaust server memory and crash the process. No authentication is required because the endpoint is public.
Fix
Each SQL query now receives LIMIT = offset + limit, so the server never reads more rows than the caller can consume. One-line addition per query, no pagination semantics change.
The same pattern is applied to the epoch_rewards and pending_ledger subqueries.
Test file
node/test_wallet_history_oom_poc.py
Section A verifies via source scan that each of the three subqueries now contains a SQL LIMIT keyword. Section B verifies via direct SQLite queries that each bounded query returns at most limit rows when the DB contains far more.
All 7 tests pass after the fix.
Wallet
RTC64aa3fc417e75224e1574acae906fea34d94d140