From e938767d668c3444235fbb17891e1dd40dd51faf Mon Sep 17 00:00:00 2001 From: ddidderr Date: Wed, 2 Sep 2026 22:27:39 +0200 Subject: [PATCH] fix(peer): drop non-unicast or zero-port mDNS discovery candidates Security audit finding NET-02. A discovered `_lanspread._udp` record was accepted as a QUIC candidate as soon as it carried the current protocol version and a peer_id TXT entry; the resolved socket address itself was never inspected. Anyone on the LAN can publish mDNS records, so a forged advertisement could point every peer's handshake attempt at a multicast group (224.0.0.251), the broadcast address, 0.0.0.0/::, or port 0. That wastes a discovery slot per record and sprays QUIC Initial packets onto addresses no peer can ever answer from. `validated_candidate_endpoint` now rejects unspecified, multicast and IPv4 broadcast addresses as well as port 0 before the candidate enters the negotiation set. Loopback and link-local addresses stay admissible because single-host and DHCP-less LAN setups legitimately use them. Test plan: `just test` (new unit tests cover the address filter and the endpoint validator). Manually: start two peer-cli containers; discovery still works with real interface addresses. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg --- .../lanspread-peer/src/services/discovery.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/lanspread-peer/src/services/discovery.rs b/crates/lanspread-peer/src/services/discovery.rs index d5b6166..ea31518 100644 --- a/crates/lanspread-peer/src/services/discovery.rs +++ b/crates/lanspread-peer/src/services/discovery.rs @@ -390,6 +390,22 @@ async fn is_self_advertisement(info: &MdnsPeerInfo, ctx: &NetworkServiceCtx) -> .is_some_and(|peer_id| *peer_id == ctx.peer_id) } +/// Returns whether a discovered socket address is a plausible unicast QUIC +/// endpoint. mDNS records are attacker-controlled, so multicast, broadcast, +/// unspecified, and zero-port targets are dropped before any handshake packet +/// is sent toward them. +fn is_admissible_candidate_addr(addr: std::net::SocketAddr) -> bool { + if addr.port() == 0 { + return false; + } + match addr.ip() { + std::net::IpAddr::V4(ip) => { + !ip.is_unspecified() && !ip.is_multicast() && !ip.is_broadcast() + } + std::net::IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_multicast(), + } +} + fn validated_candidate_endpoint(info: &MdnsPeerInfo) -> Option { if info.proto_ver != Some(PROTOCOL_VERSION) { log::debug!( @@ -400,6 +416,14 @@ fn validated_candidate_endpoint(info: &MdnsPeerInfo) -> Option { return None; } + if !is_admissible_candidate_addr(info.addr) { + log::debug!( + "Ignoring current-protocol peer advertised at non-unicast or zero-port address {}", + info.addr + ); + return None; + } + let Some(peer_id) = info.peer_id else { log::debug!( "Ignoring current-protocol peer at {} without a peer_id TXT record", @@ -451,10 +475,13 @@ mod tests { DISCOVERY_CANDIDATE_COOLDOWN, DiscoveryWorker, MAX_ACTIVE_DISCOVERY_CANDIDATES, + MdnsPeerInfo, RecentCandidates, candidate_conflicts, candidate_is_admissible, drain_service_children, + is_admissible_candidate_addr, + validated_candidate_endpoint, }; fn endpoint(seed: u8, port: u16) -> PeerEndpoint { @@ -464,6 +491,57 @@ mod tests { ) } + #[test] + fn advertised_candidate_addresses_must_be_unicast_with_a_nonzero_port() { + for addr in [ + "192.168.1.50:42424", + "10.0.0.7:1", + "127.0.0.1:42424", + "[fe80::1]:42424", + "[::1]:42424", + ] { + let addr: SocketAddr = addr.parse().expect("test address parses"); + assert!(is_admissible_candidate_addr(addr), "rejected {addr}"); + } + for addr in [ + "192.168.1.50:0", + "0.0.0.0:42424", + "224.0.0.251:42424", + "239.255.255.250:42424", + "255.255.255.255:42424", + "[::]:42424", + "[ff02::fb]:42424", + ] { + let addr: SocketAddr = addr.parse().expect("test address parses"); + assert!(!is_admissible_candidate_addr(addr), "accepted {addr}"); + } + } + + #[test] + fn validated_candidate_endpoint_drops_non_unicast_advertisements() { + let peer_id = Some(PeerId::from_bytes([7; 32])); + let unicast = MdnsPeerInfo { + addr: "192.168.1.50:42424".parse().expect("test address parses"), + peer_id, + proto_ver: Some(lanspread_proto::PROTOCOL_VERSION), + }; + assert!(validated_candidate_endpoint(&unicast).is_some()); + + let multicast = MdnsPeerInfo { + addr: "224.0.0.251:42424".parse().expect("test address parses"), + peer_id, + proto_ver: Some(lanspread_proto::PROTOCOL_VERSION), + }; + assert!(validated_candidate_endpoint(&multicast).is_none()); + + let zero_port = MdnsPeerInfo { + addr: "192.168.1.50:0".parse().expect("test address parses"), + peer_id, + proto_ver: Some(lanspread_proto::PROTOCOL_VERSION), + }; + assert!(validated_candidate_endpoint(&zero_port).is_none()); + } + #[test] fn active_candidate_keys_bound_both_claimed_identity_and_address() { let active_endpoint = endpoint(1, 12001);