From 04ba47dd5a3c9455d2c9d810f125bc3e61e54a4d Mon Sep 17 00:00:00 2001 From: william Date: Tue, 19 May 2026 19:07:17 +0800 Subject: [PATCH 1/3] fix: reject non-finite withdrawal amounts --- node/rustchain_v2_integrated_v2.2.1_rip200.py | 2 ++ node/tests/test_withdraw_amount_validation.py | 11 +++++-- node/tests/test_withdrawal_validation.py | 31 ++++++++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 60e959f96..9b725590c 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -5128,6 +5128,8 @@ def request_withdrawal(): amount = float(raw_amount) except (TypeError, ValueError): return jsonify({"error": "amount must be a number", "received": str(type(raw_amount).__name__)}), 400 + if not math.isfinite(amount): + return jsonify({"error": "amount must be a finite positive number"}), 400 if amount < 0: return jsonify({"error": "amount must be positive"}), 400 diff --git a/node/tests/test_withdraw_amount_validation.py b/node/tests/test_withdraw_amount_validation.py index 91d8e284d..4ea339413 100644 --- a/node/tests/test_withdraw_amount_validation.py +++ b/node/tests/test_withdraw_amount_validation.py @@ -56,20 +56,25 @@ def test_invalid_json_body_rejected(self): self.assertEqual(resp.status_code, 400) self.assertEqual(resp.get_json().get("error"), "Invalid JSON body") + def test_top_level_array_body_rejected(self): + resp = self.client.post("/withdraw/request", json=["miner_pk", "amount"]) + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.get_json().get("error"), "Invalid JSON body") + def test_non_numeric_amount_rejected(self): resp = self.client.post("/withdraw/request", json=self._payload("abc")) self.assertEqual(resp.status_code, 400) - self.assertEqual(resp.get_json().get("error"), "Amount must be a number") + self.assertEqual(resp.get_json().get("error"), "amount must be a number") def test_nan_amount_rejected(self): resp = self.client.post("/withdraw/request", json=self._payload("NaN")) self.assertEqual(resp.status_code, 400) - self.assertEqual(resp.get_json().get("error"), "Amount must be a finite positive number") + self.assertEqual(resp.get_json().get("error"), "amount must be a finite positive number") def test_infinite_amount_rejected(self): resp = self.client.post("/withdraw/request", json=self._payload("inf")) self.assertEqual(resp.status_code, 400) - self.assertEqual(resp.get_json().get("error"), "Amount must be a finite positive number") + self.assertEqual(resp.get_json().get("error"), "amount must be a finite positive number") def test_minimum_withdrawal_check_still_applies(self): amount = max(0.000001, float(self.mod.MIN_WITHDRAWAL) / 2.0) diff --git a/node/tests/test_withdrawal_validation.py b/node/tests/test_withdrawal_validation.py index b50325c9f..dd85e3cd6 100644 --- a/node/tests/test_withdrawal_validation.py +++ b/node/tests/test_withdrawal_validation.py @@ -10,10 +10,14 @@ import pytest import json +import importlib.util import sys import os +import tempfile sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +NODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +MODULE_PATH = os.path.join(NODE_DIR, "rustchain_v2_integrated_v2.2.1_rip200.py") class TestWithdrawalRequestValidation: @@ -22,10 +26,29 @@ class TestWithdrawalRequestValidation: @pytest.fixture def app(self): """Create test app instance""" - import importlib - app = importlib.import_module("rustchain_v2_integrated_v2.2.1_rip200").app - app.config['TESTING'] = True - return app + with tempfile.TemporaryDirectory() as tmpdir: + prev_db_path = os.environ.get("RUSTCHAIN_DB_PATH") + prev_admin_key = os.environ.get("RC_ADMIN_KEY") + os.environ["RUSTCHAIN_DB_PATH"] = os.path.join(tmpdir, "withdrawal-validation.db") + os.environ["RC_ADMIN_KEY"] = "0123456789abcdef0123456789abcdef" + try: + spec = importlib.util.spec_from_file_location( + "rustchain_integrated_withdraw_validation_test", + MODULE_PATH, + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.app.config['TESTING'] = True + yield mod.app + finally: + if prev_db_path is None: + os.environ.pop("RUSTCHAIN_DB_PATH", None) + else: + os.environ["RUSTCHAIN_DB_PATH"] = prev_db_path + if prev_admin_key is None: + os.environ.pop("RC_ADMIN_KEY", None) + else: + os.environ["RC_ADMIN_KEY"] = prev_admin_key @pytest.fixture def client(self, app): From 5b97227564c618e211e61046b1d6fe140c4b8f7c Mon Sep 17 00:00:00 2001 From: william Date: Tue, 19 May 2026 19:41:48 +0800 Subject: [PATCH 2/3] test: avoid Windows temp db cleanup failure --- node/tests/test_withdrawal_validation.py | 52 ++++++++++++++---------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/node/tests/test_withdrawal_validation.py b/node/tests/test_withdrawal_validation.py index dd85e3cd6..7370528ca 100644 --- a/node/tests/test_withdrawal_validation.py +++ b/node/tests/test_withdrawal_validation.py @@ -10,9 +10,11 @@ import pytest import json +import gc import importlib.util import sys import os +import shutil import tempfile sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) @@ -26,29 +28,37 @@ class TestWithdrawalRequestValidation: @pytest.fixture def app(self): """Create test app instance""" - with tempfile.TemporaryDirectory() as tmpdir: - prev_db_path = os.environ.get("RUSTCHAIN_DB_PATH") - prev_admin_key = os.environ.get("RC_ADMIN_KEY") + tmpdir = tempfile.mkdtemp(prefix="withdrawal-validation-") + mod = None + prev_db_path = os.environ.get("RUSTCHAIN_DB_PATH") + prev_admin_key = os.environ.get("RC_ADMIN_KEY") + try: os.environ["RUSTCHAIN_DB_PATH"] = os.path.join(tmpdir, "withdrawal-validation.db") os.environ["RC_ADMIN_KEY"] = "0123456789abcdef0123456789abcdef" - try: - spec = importlib.util.spec_from_file_location( - "rustchain_integrated_withdraw_validation_test", - MODULE_PATH, - ) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - mod.app.config['TESTING'] = True - yield mod.app - finally: - if prev_db_path is None: - os.environ.pop("RUSTCHAIN_DB_PATH", None) - else: - os.environ["RUSTCHAIN_DB_PATH"] = prev_db_path - if prev_admin_key is None: - os.environ.pop("RC_ADMIN_KEY", None) - else: - os.environ["RC_ADMIN_KEY"] = prev_admin_key + spec = importlib.util.spec_from_file_location( + "rustchain_integrated_withdraw_validation_test", + MODULE_PATH, + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.app.config['TESTING'] = True + yield mod.app + finally: + if mod is not None: + try: + mod.app.do_teardown_appcontext() + except Exception: + pass + if prev_db_path is None: + os.environ.pop("RUSTCHAIN_DB_PATH", None) + else: + os.environ["RUSTCHAIN_DB_PATH"] = prev_db_path + if prev_admin_key is None: + os.environ.pop("RC_ADMIN_KEY", None) + else: + os.environ["RC_ADMIN_KEY"] = prev_admin_key + gc.collect() + shutil.rmtree(tmpdir, ignore_errors=True) @pytest.fixture def client(self, app): From 9b75868e69ec98533ce0a7afcb6b3c7870225b9d Mon Sep 17 00:00:00 2001 From: william Date: Tue, 19 May 2026 21:43:53 +0800 Subject: [PATCH 3/3] fix: reject boolean withdrawal amounts --- node/rustchain_v2_integrated_v2.2.1_rip200.py | 2 ++ node/tests/test_withdraw_amount_validation.py | 23 ++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 9b725590c..bba1c7d84 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -5124,6 +5124,8 @@ def request_withdrawal(): # SECURITY: Validate amount is a number (CVE-style float injection) raw_amount = data.get('amount', 0) + if isinstance(raw_amount, bool): + return jsonify({"error": "amount must be a number", "received": "bool"}), 400 try: amount = float(raw_amount) except (TypeError, ValueError): diff --git a/node/tests/test_withdraw_amount_validation.py b/node/tests/test_withdraw_amount_validation.py index 4ea339413..69c9f2a13 100644 --- a/node/tests/test_withdraw_amount_validation.py +++ b/node/tests/test_withdraw_amount_validation.py @@ -1,5 +1,7 @@ import importlib.util +import gc import os +import shutil import sys import tempfile import unittest @@ -12,10 +14,10 @@ class TestWithdrawAmountValidation(unittest.TestCase): @classmethod def setUpClass(cls): - cls._tmp = tempfile.TemporaryDirectory() + cls._tmp = tempfile.mkdtemp(prefix="withdraw-amount-validation-") cls._prev_db_path = os.environ.get("RUSTCHAIN_DB_PATH") cls._prev_admin_key = os.environ.get("RC_ADMIN_KEY") - os.environ["RUSTCHAIN_DB_PATH"] = os.path.join(cls._tmp.name, "import.db") + os.environ["RUSTCHAIN_DB_PATH"] = os.path.join(cls._tmp, "import.db") os.environ["RC_ADMIN_KEY"] = "0123456789abcdef0123456789abcdef" if NODE_DIR not in sys.path: @@ -28,6 +30,12 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): + try: + cls.mod.app.do_teardown_appcontext() + except Exception: + pass + cls.client = None + cls.mod = None if cls._prev_db_path is None: os.environ.pop("RUSTCHAIN_DB_PATH", None) else: @@ -36,7 +44,8 @@ def tearDownClass(cls): os.environ.pop("RC_ADMIN_KEY", None) else: os.environ["RC_ADMIN_KEY"] = cls._prev_admin_key - cls._tmp.cleanup() + gc.collect() + shutil.rmtree(cls._tmp, ignore_errors=True) def _payload(self, amount): return { @@ -66,6 +75,14 @@ def test_non_numeric_amount_rejected(self): self.assertEqual(resp.status_code, 400) self.assertEqual(resp.get_json().get("error"), "amount must be a number") + def test_boolean_amounts_rejected_as_non_numeric(self): + for amount in (True, False): + with self.subTest(amount=amount): + resp = self.client.post("/withdraw/request", json=self._payload(amount)) + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.get_json().get("error"), "amount must be a number") + self.assertEqual(resp.get_json().get("received"), "bool") + def test_nan_amount_rejected(self): resp = self.client.post("/withdraw/request", json=self._payload("NaN")) self.assertEqual(resp.status_code, 400)