"""Regression: the read-only RPC proxy must never forward raw client bytes. Python's json keeps the LAST duplicate key; the node's UniValue parser returns the FIRST. A body such as {"method":"sendtoaddress",...,"method":"getblockcount"} passed the allowlist (last = getblockcount) while the node ran sendtoaddress. The proxy now rejects duplicate keys and re-serializes only validated fields. """ import base64 import json import os import subprocess import sys import threading import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path import pytest PROXY = Path(__file__).resolve().parent.parent / "explorer" / "rpc_readonly_proxy.py" NODE_PORT, PROXY_PORT = 19332, 19350 class _EchoNode(BaseHTTPRequestHandler): seen = [] def do_POST(self): body = self.rfile.read(int(self.headers["Content-Length"])) _EchoNode.seen.append(body) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"result":"echo","error":null,"id":1}') def log_message(self, *a): # silence pass @pytest.fixture(scope="module") def proxy(): srv = HTTPServer(("127.0.0.1", NODE_PORT), _EchoNode) threading.Thread(target=srv.serve_forever, daemon=True).start() env = dict( os.environ, PROXY_BIND="127.0.0.1", PROXY_PORT=str(PROXY_PORT), NODE_RPC_URL=f"http://127.0.0.1:{NODE_PORT}/", NODE_RPC_USER="n", NODE_RPC_PASSWORD="np", PROXY_USER="u", PROXY_PASSWORD="p", ) proc = subprocess.Popen([sys.executable, str(PROXY)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for _ in range(50): try: urllib.request.urlopen(f"http://127.0.0.1:{PROXY_PORT}/health", timeout=1) break except Exception: time.sleep(0.1) yield proc.kill() srv.shutdown() def _call(body: str): auth = "Basic " + base64.b64encode(b"u:p").decode() req = urllib.request.Request( f"http://127.0.0.1:{PROXY_PORT}/", data=body.encode(), headers={"Authorization": auth, "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=5) as r: return r.status except urllib.error.HTTPError as e: return e.code def test_duplicate_method_key_is_rejected(proxy): _EchoNode.seen.clear() body = '{"id":1,"method":"sendtoaddress","params":["M",1000],"method":"getblockcount"}' assert _call(body) == 400 assert _EchoNode.seen == [], "node must receive nothing" def test_forwarded_body_is_reserialized_not_raw(proxy): _EchoNode.seen.clear() raw = '{"id":1,"method":"getblockcount","params":[], "junk":{"method":"stop"}}' assert _call(raw) == 200 assert len(_EchoNode.seen) == 1 got = json.loads(_EchoNode.seen[0]) assert got == {"jsonrpc": "1.0", "id": 1, "method": "getblockcount", "params": []} assert b"junk" not in _EchoNode.seen[0] def test_batch_with_one_bad_method_is_fully_refused(proxy): _EchoNode.seen.clear() assert _call('[{"id":1,"method":"getblockcount"},{"id":2,"method":"stop"}]') == 403 assert _EchoNode.seen == [] def test_non_string_method_refused(proxy): assert _call('{"id":1,"method":["getblockcount"]}') == 403 def test_dockerignore_keeps_secrets_out_of_images(): src = (Path(__file__).resolve().parent.parent / ".dockerignore").read_text() for pat in (".claude", ".git", "*.db", "*.jsonl", ".env"): assert pat in src.split(), f"{pat} missing from .dockerignore"