git / moonbite-network / tests/test_security_hardening.py · raw
"""Regression guards for the hardening pass.Each test here corresponds to a finding from the security review. They assertthe secure behaviour, so a regression re-opens the finding as a failing testrather than shipping quietly."""import sysfrom pathlib import Pathimport pytestsys.path.insert(0, str(Path(__file__).resolve().parent.parent))import web_app # noqa: E402from block import block_subsidy # noqa: E402from node import Node # noqa: E402from params import MAX_MEMPOOL_TXS, MIN_RELAY_FEE # noqa: E402from transaction import (Transaction, TxInput, TxOutput, # noqa: E402 generate_keypair, pubkey_hash)def _wallet(): sk, pub = generate_keypair() return sk, pub, pubkey_hash(pub)# --------------------------------------------------------------------------- ## H1: mining is CPU-bound, so it must be rate limited and bounded# --------------------------------------------------------------------------- #def test_mining_start_is_rate_limited(): """The most expensive endpoint was the only uncapped one.""" fn = web_app.app.view_functions["api_mining_start"] assert getattr(fn, "__wrapped__", None) is not None, ( "api_mining_start is not wrapped by @rate_limit — a caller could loop " "it and pin the host's CPU" )def test_concurrent_mining_jobs_are_capped(): assert web_app._MAX_CONCURRENT_MINING_JOBS >= 1 assert web_app._MAX_CONCURRENT_MINING_JOBS <= 8, ( "an unbounded or very high job cap defeats the purpose on a small host" )def test_mining_refuses_work_when_at_capacity(monkeypatch): client = web_app.app.test_client() # Fill the job table with running jobs. with web_app.app.mining_lock: web_app.app.mining_state["active_jobs"].clear() for i in range(web_app._MAX_CONCURRENT_MINING_JOBS): web_app.app.mining_state["active_jobs"][f"busy{i}"] = { "is_mining": True, "blocks_to_mine": 1, "blocks_mined": 0, "hashes_tried": 0, "hashrate": 0.0, } try: res = client.post("/api/mining/start", json={"blocks": 1, "address": "moon1whatever"}) assert res.status_code == 429 finally: with web_app.app.mining_lock: web_app.app.mining_state["active_jobs"].clear()# --------------------------------------------------------------------------- ## M3: the mempool must not accept free or unlimited transactions# --------------------------------------------------------------------------- #def test_zero_fee_transaction_is_not_relayed(): """Validation accepts inputs == outputs, so spam used to be free.""" node = Node("solo", coinbase_maturity=0) sk, _, pkh = _wallet() _, _, dest = _wallet() block = node.mine_block(pkh) coinbase = block.transactions[0] # Spend the entire subsidy: valid, but pays no fee. tx = Transaction([TxInput(coinbase.txid, 0)], [TxOutput(block_subsidy(1), dest)]) tx.sign_input(0, sk) assert node.chain.add_to_mempool(tx) is Falsedef test_transaction_paying_the_minimum_fee_is_relayed(): node = Node("solo", coinbase_maturity=0) sk, _, pkh = _wallet() _, _, dest = _wallet() block = node.mine_block(pkh) coinbase = block.transactions[0] tx = Transaction([TxInput(coinbase.txid, 0)], [TxOutput(block_subsidy(1) - MIN_RELAY_FEE, dest)]) tx.sign_input(0, sk) assert node.chain.add_to_mempool(tx) is Truedef test_mempool_has_a_hard_ceiling(): assert MAX_MEMPOOL_TXS > 0 node = Node("solo", coinbase_maturity=0) # Pretend the mempool is already full; a new tx must be refused before any # validation work is done. node.chain.mempool = {f"tx{i}": object() for i in range(MAX_MEMPOOL_TXS)} sk, _, pkh = _wallet() _, _, dest = _wallet() tx = Transaction([TxInput("aa" * 32, 0)], [TxOutput(1000, dest)]) tx.sign_input(0, sk) assert node.chain.add_to_mempool(tx) is False# --------------------------------------------------------------------------- ## L2: the Werkzeug debugger is an RCE console; it must never default on# --------------------------------------------------------------------------- #def test_flask_debug_defaults_off(monkeypatch): monkeypatch.delenv("FLASK_DEBUG", raising=False) import os assert (os.environ.get("FLASK_DEBUG", "0") == "1") is Falsedef test_source_does_not_default_debug_on(): src = (Path(__file__).resolve().parent.parent / "web_app.py").read_text( encoding="utf-8") assert 'os.environ.get("FLASK_DEBUG", "1")' not in src, ( "FLASK_DEBUG must not default to 1 — that enables the remote debugger" )# --------------------------------------------------------------------------- ## L1: the wallet must not need 'unsafe-inline' to run# --------------------------------------------------------------------------- #def test_wallet_csp_has_no_unsafe_inline_for_scripts(): """'unsafe-inline' lets an injected <script> execute — the XSS vector.""" client = web_app.app.test_client() csp = client.get("/wallet").headers.get("Content-Security-Policy", "") script_src = [d for d in csp.split(";") if d.strip().startswith("script-src")] assert script_src, "no script-src directive on the wallet" assert "unsafe-inline" not in script_src[0], ( "the wallet is back to allowing inline script execution" ) assert "nonce-" in script_src[0], "the wallet's script-src carries no nonce"def test_wallet_nonce_is_per_response(): client = web_app.app.test_client() a = client.get("/wallet").headers.get("Content-Security-Policy", "") b = client.get("/wallet").headers.get("Content-Security-Policy", "") assert a != b, "the CSP nonce is being reused across responses"def test_wallet_has_no_inline_event_handlers(): """Inline handlers are what forced 'unsafe-inline' in the first place.""" import re client = web_app.app.test_client() html = client.get("/wallet").get_data(as_text=True) found = re.findall(r'\son(?:click|keyup|change|input|submit|load)=', html) assert not found, f"{len(found)} inline handler(s) reintroduced in the wallet"def test_every_data_fn_is_whitelisted(): """Dispatch is by allow-list; an unmapped data-fn would silently no-op.""" import re from pathlib import Path src = (Path(__file__).resolve().parent.parent / "templates" / "wallet-pwa-app.html").read_text(encoding="utf-8") used = set(re.findall(r'data-fn="(\w+)"', src)) block = src[src.index("const MB_ACTIONS = {"):src.index("function mbDispatch")] mapped = set(re.findall(r"^\s{12}(\w+):", block, re.M)) assert not (used - mapped), f"unmapped data-fn: {sorted(used - mapped)}"def test_no_template_has_inline_event_handlers(): """Site-wide: one inline handler anywhere forces 'unsafe-inline' back.""" import re from pathlib import Path root = Path(__file__).resolve().parent.parent / "templates" offenders = {} for f in root.rglob("*.html"): found = re.findall( r'\son(?:click|keyup|change|input|submit|load)=', f.read_text(encoding="utf-8", errors="replace")) if found: offenders[f.name] = len(found) assert not offenders, f"inline handlers reintroduced: {offenders}"def test_every_inline_script_carries_a_nonce(): """An un-nonced inline script is silently blocked under the strict policy.""" import re from pathlib import Path root = Path(__file__).resolve().parent.parent / "templates" offenders = {} for f in root.rglob("*.html"): src = f.read_text(encoding="utf-8", errors="replace") bad = [t for t in re.findall(r"<script(?:\s[^>]*)?>", src) if "src=" not in t and "nonce=" not in t] if bad: offenders[f.name] = len(bad) assert not offenders, f"inline scripts without a nonce: {offenders}"@pytest.mark.parametrize("route", [ "/", "/wallet", "/explorer", "/mining", "/leaderboard", "/halving", "/calculator", "/free", "/wall", "/start",])def test_every_page_serves_a_strict_csp(route): client = web_app.app.test_client() res = client.get(route) if res.status_code != 200: pytest.skip(f"{route} not served ({res.status_code})") csp = res.headers.get("Content-Security-Policy", "") script_src = [d for d in csp.split(";") if d.strip().startswith("script-src")] assert script_src, f"{route} has no script-src" assert "unsafe-inline" not in script_src[0], f"{route} allows inline script" assert "nonce-" in script_src[0], f"{route} carries no nonce"# --------------------------------------------------------------------------- ## Audit 2026-10-03: no deploy file may open the node RPC to the whole internet# --------------------------------------------------------------------------- #def test_network_installers_fail_closed_on_unverified_binaries(): """setup-node.sh / setup-seed2.sh must REFUSE to install a network binary with no verification by default (the supply-chain backdoor), permitting it only behind an explicit MOONBITE_ALLOW_UNVERIFIED=1 operator override. Guard that the fail-open 'WARNING ... installing UNVERIFIED ... <then install>' path is not reintroduced.""" import re deploy = Path(__file__).resolve().parent.parent / "deploy" for name in ("setup-node.sh", "setup-seed2.sh"): src = (deploy / name).read_text(encoding="utf-8") assert "MOONBITE_ALLOW_UNVERIFIED" in src, f"{name}: no explicit unverified override gate" # The default (no override) branch must abort, not install. assert re.search(r"refusing to install", src, re.IGNORECASE), \ f"{name}: no fail-closed refusal for unverified installs" assert "exit 1" in src, f"{name}: refusal does not abort"def test_no_deploy_file_opens_node_rpc_to_the_world(): """rpcallowip=0.0.0.0/0 (or ::/0) exposes a wallet-loaded node's RPC to the internet behind only Basic auth — one weak password = full wallet drain. Guard every deploy script/conf so this can never be reintroduced.""" import re root = Path(__file__).resolve().parent.parent / "deploy" bad = re.compile(r"rpcallowip\s*=\s*(0\.0\.0\.0/0|::/0)") offenders = {} for f in root.rglob("*"): if f.is_file() and f.suffix in (".sh", ".conf", ".json", ".env", "") or f.name.startswith("entrypoint"): try: text = f.read_text(encoding="utf-8", errors="replace") except (OSError, UnicodeDecodeError): continue if bad.search(text): offenders[str(f.relative_to(root))] = True assert not offenders, f"deploy files open node RPC to the world: {sorted(offenders)}"# --------------------------------------------------------------------------- ## Audit 2026-10-03: the unauthenticated cloud seed-backup store is retired# --------------------------------------------------------------------------- #@pytest.mark.parametrize("method,route", [ ("post", "/api/wallet/backup/create"), ("post", "/api/wallet/backup/restore"), ("get", "/api/wallet/backup/status"),])def test_seed_backup_endpoints_are_gone(method, route): """These stored/returned an encrypted seed blob with no per-user auth. They must be fully retired (410), never writing a seed to disk again.""" client = web_app.app.test_client() res = getattr(client, method)(route, json={"encryptedSeed": "x", "backupId": "0" * 16}) assert res.status_code == 410, f"{route} should be 410 Gone, got {res.status_code}" body = res.get_json() or {} assert "encryptedSeed" not in body# --------------------------------------------------------------------------- ## Audit 2026-10-03: hardware-wallet stubs must not return fake fund data# --------------------------------------------------------------------------- #def test_hardware_wallet_endpoints_return_no_fake_address_or_signature(): """The stub returned a simulated moon1 address (all-zero pubkey-hash, funds sent to it are burned) and fake signatures. It must be a clear 501, never fund-relevant data.""" client = web_app.app.test_client() addr = client.post("/api/hardware-wallet/address", json={"device": "ledger"}) sign = client.post("/api/hardware-wallet/sign", json={}) for res in (addr, sign): assert res.status_code == 501, f"expected 501, got {res.status_code}" body = res.get_json() or {} assert "address" not in body and "signature" not in body# --------------------------------------------------------------------------- ## The returning-user login must actually authenticate# --------------------------------------------------------------------------- #def test_no_fake_biometric_login(): """'Use Fingerprint/Face ID' called no WebAuthn API - it showed the dashboard unauthenticated, bypassing the PIN for anyone holding the device. Until a real passkey flow exists, no biometric claim may appear and no login path may reach the dashboard without verification.""" src = (Path(__file__).resolve().parent.parent / "templates" / "wallet-pwa-app.html").read_text(encoding="utf-8") assert "Use Fingerprint/Face ID" not in src body = src[src.index("function tryBiometricLogin"):] body = body[:body.index("\n }")] assert "dashboardScreen" not in body, ( "tryBiometricLogin routes to the dashboard without authenticating" )