git / moonbite-network / tests/test_explorer_address_hardening.py · raw

"""Explorer address-endpoint hardening (audit rpcweb findings).The public explorer's /api/address/<addr>/{utxos,balance} routes drove anunbounded scantxoutset walk on any input, with no rate limit, address check, orcache — a node-resource DoS. These tests pin the fixes: junk input is rejectedbefore any scan, repeat lookups are served from a short cache, and the routesare rate limited.Same fake-client harness as test_explorer_mine_guardrail.py; no real node."""import osimport sysimport pytestfrom flask import Flask_EXPLORER_DIR = os.path.join(    os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "explorer")if _EXPLORER_DIR not in sys.path:    sys.path.insert(0, _EXPLORER_DIR)import api  # noqa: E402class FakeClient:    """Records scantxoutset calls; treats only moon1… addresses as valid."""    def __init__(self):        self.scan_calls = 0        self.validate_calls = 0    def is_demo(self):        return False    def validateaddress(self, address):        self.validate_calls += 1        return {"isvalid": str(address).startswith("moon1")}    def scantxoutset(self, action, scanobjects):        self.scan_calls += 1        return {"success": True, "height": 100, "unspents": []}@pytest.fixture()def env(monkeypatch):    # Reset the module-global limiter + scan cache so tests are independent.    api._rl_hits.clear()    api._scan_cache.clear()    fake = FakeClient()    monkeypatch.setattr(api, "_client", lambda: fake)    app = Flask(__name__)    app.register_blueprint(api.api)    return app.test_client(), fakedef test_invalid_address_returns_400_without_scanning(env):    client, fake = env    r = client.get("/api/address/not-an-address/utxos")    assert r.status_code == 400    assert r.get_json()["error"] == "invalid address"    assert fake.scan_calls == 0  # the expensive walk never randef test_valid_address_scans_and_then_caches(env):    client, fake = env    a = "moon1qexampleexampleexampleexample"    r1 = client.get(f"/api/address/{a}/utxos")    r2 = client.get(f"/api/address/{a}/balance")    assert r1.status_code == 200 and r2.status_code == 200    # Second lookup within the TTL is served from cache: only ONE scan total.    assert fake.scan_calls == 1def test_rate_limit_trips_on_the_16th_call(env):    client, fake = env    a = "moon1qexampleexampleexampleexample"    codes = [client.get(f"/api/address/{a}/utxos").status_code for _ in range(16)]    assert codes[:15] == [200] * 15    assert codes[15] == 429