Skip to content

Commit 7453e47

Browse files
authored
feat(gossipsub): improve message size validation
Pull-Request: #6468.
1 parent e156a22 commit 7453e47

8 files changed

Lines changed: 252 additions & 106 deletions

File tree

misc/prost-codec/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
## 0.4.0
22

33
- Raise MSRV to 1.88.0.
4+
- Add `decode_field_tag`, `consume_message`, and `consume_message_prefix` helper functions
5+
for parsing protobuf wire format without allocating.
6+
See [PR 6428](https://github.com/libp2p/rust-libp2p/pull/6428).
47
See [PR 6273](https://github.com/libp2p/rust-libp2p/pull/6273).
58
- Migrate from `prost` 0.11 to `prost` 0.14.
69
- Replace `UviBytes`-based framing with manual unsigned-varint encoding/decoding for improved performance.

misc/prost-codec/src/lib.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use std::{io, marker::PhantomData};
44

55
use asynchronous_codec::{Decoder, Encoder};
66
use bytes::{Buf, BytesMut};
7-
use prost::Message;
7+
use prost::{
8+
Message,
9+
encoding::{DecodeContext, WireType},
10+
};
811

912
mod generated;
1013

@@ -101,7 +104,7 @@ where
101104
}
102105

103106
#[derive(thiserror::Error, Debug)]
104-
#[error("Failed to encode/decode message")]
107+
#[error("Failed to encode/decode message: {0}")]
105108
pub struct Error(#[from] io::Error);
106109

107110
impl From<Error> for io::Error {
@@ -110,6 +113,37 @@ impl From<Error> for io::Error {
110113
}
111114
}
112115

116+
/// Consumes the varint length prefix from a length-prefixed buffer.
117+
/// Advances the buffer past the prefix, positioning it at the message bytes.
118+
pub fn consume_message_prefix(buf: &mut &[u8]) -> io::Result<bool> {
119+
let (message_length, remaining) = match unsigned_varint::decode::usize(buf) {
120+
Ok((len, remaining)) => (len, remaining),
121+
Err(unsigned_varint::decode::Error::Insufficient) => return Ok(false),
122+
Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e)),
123+
};
124+
if remaining.len() < message_length {
125+
return Ok(false);
126+
}
127+
// Advance buffer past the length prefix, capping at the end of the buffer.
128+
*buf = &remaining[..message_length];
129+
Ok(true)
130+
}
131+
132+
/// Decodes a protobuf field key (tag + wire type) and returns them.
133+
/// Advances the buffer past the key only (does NOT consume the field value).
134+
pub fn decode_field_tag(buf: &mut &[u8]) -> io::Result<(u32, WireType)> {
135+
use prost::encoding::decode_key;
136+
decode_key(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
137+
}
138+
139+
/// Consumes a length-delimited protobuf message from the buffer.
140+
/// Advances the buffer past the message.
141+
pub fn consume_message(wire_type: WireType, tag: u32, buf: &mut &[u8]) -> io::Result<()> {
142+
use prost::encoding::skip_field;
143+
skip_field(wire_type, tag, buf, DecodeContext::default())
144+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
145+
}
146+
113147
#[cfg(test)]
114148
mod tests {
115149
use std::error::Error;

protocols/gossipsub/CHANGELOG.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
## 0.50.0
2+
- Simplify gossipsub control message validation by scanning protobuf bytes before decoding
3+
to validate cumulative control message size, rather than decoding then counting individual IDs
4+
to prevent memory amplification from decoding.
5+
Introduce `max_control_message_size` (default 16KB) to limit total control bytes in an RPC.
6+
Rename `max_ihave_messages` to `max_ihave_messages_heartbeat`.
7+
Rename `max_control_messages` to `max_control_messages_sent`.
8+
See [PR #6486](https://github.com/libp2p/rust-libp2p/pull/6486)
9+
210
- Send all topic subscriptions in a single hello RPC when connecting to a new peer, aligning with the GossipSub spec and other implementations (Go, Nim, JS).
311
See [PR 6385](https://github.com/libp2p/rust-libp2p/pull/6385).
412

@@ -11,12 +19,6 @@
1119
- Optimize IDONTWANT sending by avoiding broadcasts for already-seen messages and deduplicating recipient peers.
1220
See [PR 6356](https://github.com/libp2p/rust-libp2p/pull/6356)
1321

14-
- Unify gossipsub control-message limits under max_control_messages (replacing per-type control ID caps),
15-
and truncate control vectors immediately after RPC decode.
16-
rename `max_ihave_messages` to `max_ihave_messages_heartbeat`.
17-
Introduce `max_ids_per_control_message` to limit the number of message IDs per control message.
18-
See [PR 6409](https://github.com/libp2p/rust-libp2p/pull/6409) and [PR 6428](https://github.com/libp2p/rust-libp2p/pull/6428)
19-
2022
- Rename metric `topic_msg_sent_bytes` to `topic_msg_last_sent_bytes` for accuracy.
2123
See [PR 6283](https://github.com/libp2p/rust-libp2p/pull/6283)
2224

protocols/gossipsub/src/behaviour.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,7 +1352,7 @@ where
13521352
}
13531353

13541354
if let Some(iasked) = self.count_sent_iwant.get(peer_id)
1355-
&& *iasked >= self.config.max_control_messages()
1355+
&& *iasked >= self.config.max_control_messages_sent()
13561356
{
13571357
tracing::debug!(
13581358
peer=%peer_id,
@@ -1411,8 +1411,11 @@ where
14111411
if !iwant_ids.is_empty() {
14121412
let iasked = self.count_sent_iwant.entry(*peer_id).or_insert(0);
14131413
let mut iask = iwant_ids.len();
1414-
if *iasked + iask > self.config.max_control_messages() {
1415-
iask = self.config.max_control_messages().saturating_sub(*iasked);
1414+
if *iasked + iask > self.config.max_control_messages_sent() {
1415+
iask = self
1416+
.config
1417+
.max_control_messages_sent()
1418+
.saturating_sub(*iasked);
14161419
}
14171420

14181421
// Send the list of IWANT control messages
@@ -2797,7 +2800,7 @@ where
27972800
}
27982801

27992802
// if we are emitting more than GossipSubMaxIHaveLength message_ids, truncate the list
2800-
if message_ids.len() > self.config.max_control_messages() {
2803+
if message_ids.len() > self.config.max_control_messages_sent() {
28012804
// we do the truncation (with shuffling) per peer below
28022805
tracing::debug!(
28032806
"too many messages for gossip; will truncate IHAVE list ({} messages)",
@@ -2839,12 +2842,13 @@ where
28392842
for peer_id in to_msg_peers {
28402843
let mut peer_message_ids = message_ids.clone();
28412844

2842-
if peer_message_ids.len() > self.config.max_control_messages() {
2845+
if peer_message_ids.len() > self.config.max_control_messages_sent() {
28432846
// We do this per peer so that we emit a different set for each peer.
28442847
// we have enough redundancy in the system that this will significantly increase
28452848
// the message coverage when we do truncate.
2846-
peer_message_ids.partial_shuffle(&mut rng, self.config.max_control_messages());
2847-
peer_message_ids.truncate(self.config.max_control_messages());
2849+
peer_message_ids
2850+
.partial_shuffle(&mut rng, self.config.max_control_messages_sent());
2851+
peer_message_ids.truncate(self.config.max_control_messages_sent());
28482852
}
28492853

28502854
// send an IHAVE message

protocols/gossipsub/src/behaviour/tests/gossip.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ fn test_ignore_too_many_ihaves() {
530530
fn test_ignore_too_many_messages_in_ihave() {
531531
let config = ConfigBuilder::default()
532532
.max_ihave_messages_heartbeat(10)
533-
.max_control_messages(10)
533+
.max_control_messages_sent(10)
534534
.build()
535535
.unwrap();
536536
// build gossipsub with full mesh
@@ -611,7 +611,7 @@ fn test_ignore_too_many_messages_in_ihave() {
611611
fn test_limit_number_of_message_ids_inside_ihave() {
612612
let config = ConfigBuilder::default()
613613
.max_ihave_messages_heartbeat(10)
614-
.max_control_messages(100)
614+
.max_control_messages_sent(100)
615615
.build()
616616
.unwrap();
617617
// build gossipsub with full mesh

protocols/gossipsub/src/behaviour/tests/topic_config.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,6 @@ fn test_validation_error_message_size_too_large_topic_specific() {
671671
max_transmit_size_map,
672672
5000,
673673
5000,
674-
5000,
675674
);
676675
let mut buf = BytesMut::new();
677676
let rpc = proto::Rpc {
@@ -781,7 +780,6 @@ fn test_validation_message_size_within_topic_specific() {
781780
max_transmit_size_map,
782781
5000,
783782
5000,
784-
5000,
785783
);
786784
let mut buf = BytesMut::new();
787785
let rpc = proto::Rpc {

protocols/gossipsub/src/config.rs

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ pub struct Config {
126126
#[cfg(feature = "partial-messages")]
127127
max_metadata_length: usize,
128128
max_publish_messages: usize,
129-
max_control_messages: usize,
130-
max_ids_per_control_message: usize,
129+
max_control_message_size: usize,
130+
max_control_messages_sent: usize,
131131
max_ihave_messages_heartbeat: usize,
132132
iwant_followup_time: Duration,
133133
connection_handler_queue_len: usize,
@@ -425,17 +425,18 @@ impl Config {
425425
self.max_publish_messages
426426
}
427427

428-
/// The maximum number of control messages by type we will process in a given RPC. The default
429-
/// is 5000.
430-
pub fn max_control_messages(&self) -> usize {
431-
self.max_control_messages
428+
/// The maximum number of control messages (IHAVE/IWANT) we will send/receive to/from a peer.
429+
/// This limits the number of IHAVE messages sent during gossip and IWANT requests received.
430+
/// The default is 5000.
431+
pub fn max_control_messages_sent(&self) -> usize {
432+
self.max_control_messages_sent
432433
}
433434

434-
/// The maximum number of message ids per IHAVE/IWANT/IDONTWANT control message we will
435-
/// process in a given RPC. Other control message types (GRAFT, PRUNE) are not affected by
436-
/// this limit as they do not contain message IDs. The default is 5000.
437-
pub fn max_ids_per_control_message(&self) -> usize {
438-
self.max_ids_per_control_message
435+
/// The maximum total byte size of all control messages and subscriptions in an RPC.
436+
/// Validates cumulative size by scanning protobuf bytes before decoding.
437+
/// Messages exceeding this limit will be rejected. The default is 16KB.
438+
pub fn max_control_message_size(&self) -> usize {
439+
self.max_control_message_size
439440
}
440441

441442
/// Time to wait for a message requested through IWANT following an IHAVE advertisement.
@@ -552,8 +553,8 @@ impl Default for ConfigBuilder {
552553
#[cfg(feature = "partial-messages")]
553554
max_metadata_length: 1000,
554555
max_publish_messages: 5000,
555-
max_control_messages: 5000,
556-
max_ids_per_control_message: 5000,
556+
max_control_messages_sent: 5000,
557+
max_control_message_size: 16384, // 16KB
557558
max_ihave_messages_heartbeat: 10,
558559
iwant_followup_time: Duration::from_secs(3),
559560
connection_handler_queue_len: 5000,
@@ -993,6 +994,14 @@ impl ConfigBuilder {
993994
self
994995
}
995996

997+
/// The maximum number of control messages (IHAVE/IWANT) we will send/receive to/from a peer.
998+
/// This limits the number of IHAVE messages sent during gossip and IWANT requests received.
999+
/// The default is 5000.
1000+
pub fn max_control_messages_sent(&mut self, max_control_messages: usize) -> &mut Self {
1001+
self.config.max_control_messages_sent = max_control_messages;
1002+
self
1003+
}
1004+
9961005
/// By default, gossipsub will reject messages that are sent to us that has the same message
9971006
/// source as we have specified locally. Enabling this, allows these messages and prevents
9981007
/// penalizing the peer that sent us the message. Default is false.
@@ -1063,20 +1072,12 @@ impl ConfigBuilder {
10631072
self
10641073
}
10651074

1066-
/// The maximum number of control messages by type we will process in a single RPC. The default
1067-
/// is 5000.
1068-
pub fn max_control_messages(&mut self, size: usize) -> &mut Self {
1069-
self.config.max_control_messages = size;
1070-
self.config.protocol.max_control_messages = size;
1071-
self
1072-
}
1073-
1074-
/// The maximum number of message ids per IHAVE/IWANT/IDONTWANT control message we will
1075-
/// process in a single RPC. Other control message types (GRAFT, PRUNE) are not affected by
1076-
/// this limit as they do not contain message IDs. The default is 5000.
1077-
pub fn max_ids_per_control_message(&mut self, size: usize) -> &mut Self {
1078-
self.config.max_ids_per_control_message = size;
1079-
self.config.protocol.max_ids_per_control_message = size;
1075+
/// The maximum total byte size of all control messages and subscriptions in an RPC.
1076+
/// Validates cumulative size by scanning protobuf bytes before decoding.
1077+
/// Messages exceeding this limit will be rejected. The default is 16KB.
1078+
pub fn max_control_message_size(&mut self, size: usize) -> &mut Self {
1079+
self.config.max_control_message_size = size;
1080+
self.config.protocol.max_control_message_size = size;
10801081
self
10811082
}
10821083

@@ -1189,11 +1190,8 @@ impl std::fmt::Debug for Config {
11891190
let _ = builder.field("opportunistic_graft_ticks", &self.opportunistic_graft_ticks);
11901191
let _ = builder.field("opportunistic_graft_peers", &self.opportunistic_graft_peers);
11911192
let _ = builder.field("max_messages_per_rpc", &self.max_publish_messages);
1192-
let _ = builder.field("max_control_messages", &self.max_control_messages);
1193-
let _ = builder.field(
1194-
"max_ids_per_control_message",
1195-
&self.max_ids_per_control_message,
1196-
);
1193+
let _ = builder.field("max_control_messages_sent", &self.max_control_messages_sent);
1194+
let _ = builder.field("max_control_message_size", &self.max_control_message_size);
11971195
let _ = builder.field(
11981196
"max_ihave_messages_heartbeat",
11991197
&self.max_ihave_messages_heartbeat,

0 commit comments

Comments
 (0)