Skip to content

Ask the node only what the whitelist allows, and only when needed - #27

Merged
Wired4ncer merged 1 commit into
mainfrom
fix/follower-rpc-whitelist
Aug 9, 2026
Merged

Wired4ncer merged 1 commit into
mainfrom
fix/follower-rpc-whitelist

Conversation

@Wired4ncer

Copy link
Copy Markdown
Owner

Two defects in the chain follower and one in the mutation sweep. All three were found by reading the node's configuration and by killing a process — not by running the suite, which was green throughout.

The follower called an RPC the node does not allow

The node's RPC whitelist for this service grants scantxoutset, getblockchaininfo, getblockcount, getbestblockhash, getblockhash, getblock, getrawmempool and uptime.

getblockheader is not on it. The follower called it for every height lookup and every active-chain check, so the first real catch-up would have failed against a production node whose whitelist is doing exactly what it is meant to do — while passing every test here, because a fake answers whatever it is asked.

getblock at verbosity 1 carries the same two fields, height and confirmations, plus a txid list we ignore. That is a larger response in exchange for not widening a whitelist, and it is the right way round: the whitelist is the difference between a compromised service being able to read the chain and being able to act on the node. A restart to add the method was available and deliberately not taken.

A test now asserts getblockheader is never called, because the forbidden call is the natural one to reach for and this will be rewritten by someone eventually.

Every block asked the node for its height

Including on the contiguous path — the one that runs every ten minutes forever. The child of the block we just applied is one above it by definition, so that was a round trip to be told what we already knew, and a call that can fail during an outage is a call worth not making. Heights are now counted locally, and asked for only on the catch-up path and for the very first block.

The test covering this asserted the opposite of its own name: test_a_contiguous_block_is_applied_without_asking_the_node_for_anything asserted that the node was asked for a header. It now asserts what it claims.

The mutation sweep was leaving broken code on disk

This is the one worth reading.

try/finally covers exceptions and Ctrl-C. It does not cover SIGTERM — which is what timeout sends, what CI sends when a job is cancelled, and what most supervisors send first. A sweep run as timeout 900 python tools/mutate.py that hit its limit left the last mutation applied. Silently: the shell reports 124, the tree looks fine, and the source quietly contains deliberately broken code.

Not hypothetical. It left this in a working tree today:

-    if len(master) < MIN_MASTER_BYTES:
+    if False:
         raise ValueError("master secret too short")

A disabled key-length check, one git commit -a away from being real.

Fixed in the order the failures matter:

  1. A handler for SIGTERM and SIGINT, so the ordinary kill paths restore.
  2. A journal written before each mutation, so the next run repairs what a SIGKILL, an OOM kill or a power cut left behind — none of which can be intercepted. This is the same reasoning as node/supervisor.py's startup abort, which exists because the only complete answer to "did a previous run leave something running?" is to ask on the way in.

Both paths were verified by killing a sweep each way and inspecting the tree, not by reasoning about them:

  • SIGTERM → source intact, no journal left.
  • SIGKILL → mutation on disk and a journal beside it; the next run printed restored … — a previous run was killed mid-mutation and repaired it before its baseline.

⚠️ If you have run tools/mutate.py under a timeout before this lands, check git diff src/ on that tree. The failure leaves no trace in the exit status.

Checks

176 tests, ruff clean, mutation sweep 67/67 with 3 documented equivalents.

Refs #24

🤖 Generated with Claude Code

Two defects, both found by reading the node's configuration rather than by
running the suite -- which is the point worth keeping.

The follower called `getblockheader`. The node's RPC whitelist for this service
grants scantxoutset, getblockchaininfo, getblockcount, getbestblockhash,
getblockhash, getblock, getrawmempool and uptime. Not that one. So every test
passed against a fake that answers whatever it is asked, and the first real
catch-up would have failed on a production node with a whitelist that is doing
exactly what it is meant to do.

`getblock` at verbosity 1 carries the same two fields -- height and
confirmations -- plus a txid list we ignore. A larger response in exchange for
not widening a whitelist, which is the right trade: the whitelist is the
difference between a compromised service being able to read the chain and being
able to act on the node. A restart to add the method was available and was not
taken for that reason.

The second defect was in the same place and cost more. Every block applied
asked the node for its height, including on the contiguous path that runs every
ten minutes forever. The child of the block we just applied is one above it by
definition, so that was a round trip to be told what we already knew -- and a
call that can fail during an outage is a call worth not making. Heights are now
counted locally and asked for only on the catch-up path and for the very first
block.

A test asserted this the wrong way round: it was named "without asking the node
for anything" while asserting that the node was asked for a header. It now
asserts what its name claims, and a second test asserts `getblockheader` is
never called at all, because the forbidden call is the natural one to reach for.

Separately, the sweep was leaving mutations on disk.

`try/finally` covers exceptions and Ctrl-C. It does not cover SIGTERM, which is
what `timeout` sends, what CI sends on cancellation, and what a supervisor sends
first. A sweep run under `timeout` that hit its limit left the last mutation
applied, silently: the shell reports 124, the tree looks fine, and the source
quietly contains broken code. This was not hypothetical -- it left the master
secret length check as `if False:` in a working tree today, which is a disabled
security check one `git commit -a` away from being real.

Fixed in the order the failures matter. A handler restores on SIGTERM and
SIGINT. A journal written before each mutation lets the *next* run repair what a
SIGKILL, an OOM kill or a power cut left behind, since none of those can be
intercepted -- the same reasoning as the scan supervisor's startup abort, which
exists for the same reason. Both paths were verified by actually killing a sweep
each way and checking the tree.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Wired4ncer
Wired4ncer merged commit 4dfdc19 into main Aug 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant