Skip to content

fix: remote-input DoS hardening (unbounded allocation, panics, missing limits)#514

Open
varex83agent wants to merge 13 commits into
mainfrom
bohdan/remote-input-dos-hardening
Open

fix: remote-input DoS hardening (unbounded allocation, panics, missing limits)#514
varex83agent wants to merge 13 commits into
mainfrom
bohdan/remote-input-dos-hardening

Conversation

@varex83agent

Copy link
Copy Markdown
Collaborator

Summary

Implements the Remote-Input DoS Hardening plan (P0): size/count caps, fail-closed parsing, bounded concurrency, and bounded metric cardinality on attacker- or upstream-influenced input. Fixes two directly exploitable issues (a remotely-triggerable panic and an unbounded allocation) plus a set of unbounded-growth / amplification hardening items.

Each commit is one task from the plan (T08 split into its 3 natural parts). Every cap is sized as a strict superset of legitimate input, so no valid input is rejected.

What's fixed

Task Fix Crate
T01 SemVer::parse no longer .expect()-panics on integer overflow from a peer-sent version string — fails closed with InvalidFormat (callers already handle Err) core
T02 Legacy cluster definitions (v1.0–v1.4) cap num_validators at SSZ_MAX_VALIDATORS (65536) before the repeat_v_addresses clone loop, killing the OOM vector cluster
T03 parse_bitlist rejects an all-zero final byte instead of the 1u8 << 255 debug-panic / masked-shift ssz
T04 PeerStore.inactive_peers bounded to 1024 with oldest-first eviction (never evicting known peers); peer_addresses pruned on disconnect p2p
T05 Identify address writes gated to known cluster peers and capped at 32 addrs/peer p2p
T06 Relay restores rust-libp2p's default per-peer/per-IP rate limiters (was vec![]) + adds a per-IP reservation cap mirroring Charon's MaxReservationsPerIP relay-server
T07 Keystore decryption bounded to 10 concurrent workers via a semaphore (matching Charon's loadStoreWorkers); KDF moved to spawn_blocking eth2util
T08 Justification count cap (consensus); HTTP body-size caps + per-request timeouts (obolapi, cluster, cli mev/beacon, eth1wrap, p2p bootnode); peer-supplied metric label truncation (peerinfo, monitoringapi) multiple

Parity notes

The following are hardening beyond Charon (flagged in code comments as intentional, safe divergences), each sized so it cannot reject a legitimate input:

  • T01 fail-closed vs Charon's silent-zero-on-overflow
  • T02 explicit num_validators cap (Charon relies on its signed int + SSZ CompositeList[65536])
  • T03 reject vs fastssz's silent-garbage wrap
  • T04/T05 (PeerStore is Pluto-specific, no Charon analogue)
  • T08 justification count cap, HTTP body-size caps, and metric label truncation

Timeouts (eth1wrap, cli diagnostics) are parity-aligned with Charon's context deadlines.

Dependency changes

  • cluster / p2p: added the stream feature to reqwest (+ futures for cluster) for streaming body-size caps
  • eth1wrap: promoted tokio from dev-dependency to dependency for the per-call timeout
  • Cargo.lock updated accordingly

Testing

  • cargo +nightly fmt --all --check — clean
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean
  • cargo test --workspace --all-features1953 passed, 0 failed

New unit tests accompany each task (overflow fail-closed, num_validators boundary at 65536/65537, zero-byte bitlist, eviction + known-peer exemption, address cap/gate, restored limiter counts, >10-file keystore loaders, justification cap boundary, capped-body read, label truncation).

🤖 Generated with Claude Code

varex83agent and others added 11 commits July 1, 2026 12:16
A peer-controlled version string with an over-long digit run (e.g. from
peerinfo/dkg-sync over the wire) overflowed usize::parse and hit
.expect("invalid regex"), panicking the per-connection handler — a
remote-triggerable DoS. Map the parse failure to
SemVerError::InvalidFormat instead; callers already handle Err.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Legacy definition versions v1.0-v1.4 route an untrusted num_validators
(u64 from JSON, reachable over the network) through repeat_v_addresses,
an unbounded clone loop, before any length/signature check. A value up
to u64::MAX drives unbounded allocation / OOM. Cap at SSZ_MAX_VALIDATORS
(65536, the CompositeList[65536] bound) inside repeat_v_addresses before
the loop, returning DefinitionError::NumValidatorsTooLarge.

Hardening beyond Charon (whose signed int + SSZ bound implicitly guard
this); it cannot reject any definition that could round-trip SSZ hashing.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
An SSZ bitlist whose final byte is 0x00 (missing the length-sentinel
bit) underflowed msb to 255, then evaluated 1u8 << 255 — a debug-mode
shift-overflow panic (masked to 1<<7 in release). Reachable via the
public HashWalker::put_bitlist on attacker-influenced input. Guard the
zero-final-byte case up front and return HasherError::InvalidBitlistDelimiter.

Deliberate fail-closed divergence from fastssz (which wraps to a bogus
length); a well-formed bitlist always has a non-zero final byte.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
PeerStore accumulated a Peer per closed connection in inactive_peers
(never trimmed) and a peer_addresses entry per identify (never removed).
On a relay (gater defaults open) a remote rotating self-asserted PeerIds
grows both without bound. Cap inactive_peers at MAX_INACTIVE_PEERS=1024
with oldest-first eviction that never drops known cluster peers (tracked
via a companion VecDeque), and drop peer_addresses on disconnect for
non-known peers with no remaining connections.

Pluto-specific structure with no Charon analogue; defensive hardening.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Every identify::Event::Received stored the peer's self-asserted
listen_addrs for any peer with no count bound. On a relay a remote can
flood addresses that are never used, since all peer_addresses consumers
look up known cluster peers only. Gate the write behind is_known_peer
(dropping unknown-peer addrs without cloning) and truncate stored
addresses to MAX_PEER_ADDRESSES=32.

Hardening beyond Charon (peer_addresses is Pluto-specific); the gate
bounds the key space to the finite cluster set. Builds on the T04
disconnect prune.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
create_relay_config built relay::Config with a struct literal that
hard-coded reservation_rate_limiters and circuit_src_rate_limiters to
empty, discarding rust-libp2p's default per-peer (30/2min) and per-IP
(60/min) token-bucket limiters — leaving the public relay open to
reservation/circuit floods. Build from relay::Config::default() (via
functional-update) to retain the four defaults, and append a per-IP
reservation rate limiter sized from max_res_per_peer, mirroring Charon's
relay.DefaultResources() + MaxReservationsPerIP = MaxResPerPeer.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
load_files_unordered spawned one async task per keystore file with no
cap and ran the CPU/memory-heavy scrypt KDF directly on the async
runtime; a directory of many keystores could spawn unbounded tasks and
starve the reactor. Gate both loaders with a 10-permit Semaphore
(acquired before spawn, matching Charon's loadStoreWorkers=10) and move
the KDF in load_files_unordered onto spawn_blocking.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Consensus::handle verified every entry in pb_msg.justification (a
secp256k1 recovery each), bounded only by the 32MB wire cap — a large
message could pack many small entries into O(many) recoveries
(Byzantine-insider CPU amplification). Reject before the loop when the
count exceeds MAX_JUSTIFICATIONS_PER_NODE * node_count(); honest QBFT
never exceeds O(node_count).

Defensive hardening beyond Charon; sized as a superset of every honest
justification set. Part 1 of T08.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Several clients read remote bodies with no size cap (and some no
timeout), letting a hostile/slow upstream exhaust memory or stall:
- obolapi http_get: stream body capped at 16MB; error bodies truncated
- cluster fetch_definition: stream body capped at 16MB before decode
- eth1wrap ERC-1271 call: wrap in a 10s per-call timeout
- p2p bootnode relay query: client timeout + 1MB body cap
- CLI mev/beacon diagnostics: shared timeout-configured client + body caps

Body caps are hardening beyond Charon, each sized well above any real
response so no legitimate body is rejected; timeouts are parity-aligned.
Part 2 of T08.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
peerinfo's nickname and version Prometheus labels are set verbatim from
peer-controlled values with no length bound (the SemVer pre-release group
is unbounded, so a v1.2.3-<megabytes> version parses and is used as a
label); monitoringapi's beacon_node_version label comes from the raw BN
version string. Truncate all three to 64 bytes on a char boundary at the
metric boundary. The peerinfo prev-nickname reset uses the same truncated
form; monitoringapi still passes the full version to the compatibility
check. git_hash and wordlist-based labels are already bounded.

Hardening beyond Charon; truncation only affects pathological inputs.
Part 3 of T08.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
@varex83 varex83 requested review from emlautarom1 and iamquang95 July 2, 2026 13:23
varex83agent and others added 2 commits July 3, 2026 17:33
…os-hardening

# Conflicts:
#	crates/ssz/src/hasher.rs
quick-xml 0.39.4 is flagged by RUSTSEC for unbounded namespace
allocation (fixed in >=0.41.0). Bump the workspace dependency and
dedupe the lockfile to a single quick-xml 0.41.0.

Co-authored-by: varex83 <ivan.uvarov02@gmail.com>
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.

2 participants