Skip to content

fix(network): canonicalize IPv4-mapped inbound peer addresses for bans and per-IP limits#238

Merged
ValarDragon merged 1 commit into
mainfrom
fix/dualstack-canonicalize-inbound
Jul 21, 2026
Merged

fix(network): canonicalize IPv4-mapped inbound peer addresses for bans and per-IP limits#238
ValarDragon merged 1 commit into
mainfrom
fix/dualstack-canonicalize-inbound

Conversation

@evan-forbes

Copy link
Copy Markdown
Contributor

Motivation

On a dual-stack listener (listen_addr = "[::]:port" with IPV6_V6ONLY=false), Linux reports an inbound IPv4 peer as an IPv4-mapped IPv6 address ::ffff:A.B.C.D. Zakura's ban map and address book are keyed on the canonical IPv4 form (A.B.C.D), but the inbound accept path and the legacy PeerSet compared the raw PeerSocketAddr::ip(), which keeps the mapped form. Insert-canonical / lookup-raw ⇒ the lookups miss.

Exploit (dual-stack binds only): a banned or rate-limited peer evades enforcement by reconnecting in the other address form:

  • A banned peer reconnecting as ::ffff:A.B.C.D is not re-banned (both the accept path and the peer set miss the canonical ban entry).
  • The per-IP recent-inbound rate limiter and the max_conns_per_ip cap (default 1) are evadable: the mapped and canonical forms count as two different hosts.

Nodes that bind 0.0.0.0 never see mapped addresses and are unaffected. The bug is invisible in regtest/e2e (those bind 127.0.0.1).

Solution

  • Accept boundary (the real fix): canonicalize the inbound address once with canonical_peer_addr in accept_inbound_connections. Every later use of addr — the ban check, the recent-inbound limiter, and the key forwarded into the handshake / PeerSet — is then canonical.
  • Peer set (defense-in-depth + testability): the PeerSet ban re-checks (poll_unready, poll_ready_peer_errors, and the broadcast_all_queued ban filter) and the per-IP accounting (num_peers_with_ip) also canonicalize before comparing, via a small shared canonical_ip helper. The boundary fix already makes all keys canonical; this enforces the invariant even if some future path inserts a raw key, and lets the equivalence be unit-tested directly by injecting a mapped key.

The zcashd-compat reserved-slot check already canonicalized (canonical_socket_addr(addr.remove_socket_addr_privacy())) and is unchanged. Genuine IPv6 peers are left untouched by canonical_peer_addr. Outbound keys come from the address book and are already canonical.

Testing

cargo fmt --all -- --check, cargo clippy -p zakura-network --all-targets -- -D warnings, and cargo test -p zakura-network (1000 passed, 0 failed) all green, including the zcashd-compat carve-out tests and prop::sidecar_peer_always_receives_block_gossip.

Two new deterministic unit tests in peer_set::set::tests::vectors:

  • per_ip_accounting_treats_mapped_and_canonical_as_same_host — inserts a connection keyed by the canonical form and asserts both the canonical and the mapped query see it as one host, then reverses the key/query roles. This is what poll_discover's max_conns_per_ip gate relies on.
  • broadcast_all_queued_bans_mapped_ipv6_against_canonical_ban — a peer keyed ::ffff:A.B.C.D against a ban map holding canonical A.B.C.D is dropped by the ban filter. All three peer-set ban re-checks share the canonical_ip helper, so this exercises that shared path.

Both tests were confirmed to fail against the un-canonicalized code (test 1 with left: 0, right: 1; test 2 with the banned mapped peer surviving the filter) and pass with the fix.

Follow-up Work

The async accept_inbound_connections loop (over a real TcpListener) isn't unit-tested directly — a TODO(test) marks the boundary canonicalization, and the equivalence is covered by the peer-set tests above. Extracting the accept decision into a pure (bans, addr) -> accept/reject helper for direct testing was left out of scope to keep the diff focused; happy to add it if a reviewer prefers.

Independent of #231 (fix/sidecar-block-gossip-unready) and #236 — different functions, no shared lines. Branched off main at v1.0.1.

@v12-auditor

v12-auditor Bot commented Jul 17, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 did not find any issues that need review.

Open the full results here.

Analyzed two files, diff 7130dca...0ae4355.

@evan-forbes
evan-forbes force-pushed the fix/dualstack-canonicalize-inbound branch from 0ae4355 to bcf91c0 Compare July 20, 2026 19:54
…s and per-IP limits

On a dual-stack listener (`[::]` with `IPV6_V6ONLY=false`), Linux reports an
inbound IPv4 peer as an IPv4-mapped IPv6 address `::ffff:A.B.C.D`. The ban map
and address book are keyed on the canonical IPv4 form, but the inbound accept
path and the legacy `PeerSet` compared the raw mapped address. Insert-canonical /
lookup-raw meant the lookups missed: a banned peer reconnecting as `::ffff:A.B.C.D`
was not re-banned, and the recent-inbound rate limiter and `max_conns_per_ip`
(default 1) were evadable by alternating the mapped and canonical forms.

Canonicalize the inbound address once at the accept boundary via
`canonical_peer_addr`, so the ban check, the recent-inbound limiter, and the key
forwarded into the `PeerSet` all use the canonical form. As defense-in-depth (and
for testability), the peer-set ban re-checks (`poll_unready`,
`poll_ready_peer_errors`, and the `broadcast_all_queued` ban filter) and the
per-IP accounting (`num_peers_with_ip`) also canonicalize before comparing, via a
shared `canonical_ip` helper, so the invariant holds even if a future path inserts
a raw key.

Only dual-stack binds are affected; nodes that bind `0.0.0.0` never see mapped
addresses. The zcashd-compat reserved-slot check already canonicalized and is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evan-forbes
evan-forbes force-pushed the fix/dualstack-canonicalize-inbound branch from bcf91c0 to 9ae206b Compare July 20, 2026 23:10
@evan-forbes
evan-forbes marked this pull request as ready for review July 20, 2026 23:10
@ValarDragon
ValarDragon merged commit 9c2e703 into main Jul 21, 2026
61 of 71 checks passed
@ValarDragon
ValarDragon deleted the fix/dualstack-canonicalize-inbound branch July 21, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants