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
This commit is contained in:
2026-09-02 22:27:39 +02:00
parent a080f93ec1
commit e938767d66
@@ -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<PeerEndpoint> {
if info.proto_ver != Some(PROTOCOL_VERSION) {
log::debug!(
@@ -400,6 +416,14 @@ fn validated_candidate_endpoint(info: &MdnsPeerInfo) -> Option<PeerEndpoint> {
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);