diff --git a/node/rustchain_v2_integrated_v2.2.1_rip200.py b/node/rustchain_v2_integrated_v2.2.1_rip200.py index 60e959f96..bba1c7d84 100644 --- a/node/rustchain_v2_integrated_v2.2.1_rip200.py +++ b/node/rustchain_v2_integrated_v2.2.1_rip200.py @@ -5124,10 +5124,14 @@ 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): 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..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 { @@ -56,20 +65,33 @@ 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_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) - 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..7370528ca 100644 --- a/node/tests/test_withdrawal_validation.py +++ b/node/tests/test_withdrawal_validation.py @@ -10,10 +10,16 @@ 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__), '..')) +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 +28,37 @@ 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 + 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" + 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):