diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index f92d0d7..923256a 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -102,7 +102,10 @@ When a peer is discovered: accepts one frame followed by request EOF and sends at most one response. Inbound request frames are capped at 64 KiB (the QUIC receive window per server stream matches it), response frames at 8 MiB, and control I/O has - ten-second deadlines. + ten-second deadlines. Wire collections (library games, Call to Play author + events) are deserialized through a bounded visitor that materializes at most + one element past the semantic limit and discards the rest, so an oversize + frame cannot claim more memory than a valid one before validation rejects it. ### Call to Play replication diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index e3f8a7c..2fe55f7 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -1,12 +1,19 @@ use std::{ fmt::{self, Write as _}, + marker::PhantomData, net::SocketAddr, str::FromStr, }; use bytes::Bytes; pub use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId}; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned}; +use serde::{ + Deserialize, + Deserializer, + Serialize, + Serializer, + de::{DeserializeOwned, IgnoredAny, SeqAccess, Visitor}, +}; pub const PROTOCOL_VERSION: u32 = 8; pub const ALPN_PROTOCOL: &[u8] = b"lanspread/8"; @@ -388,6 +395,7 @@ pub struct GameAvailability { #[serde(deny_unknown_fields)] pub struct LibrarySnapshot { pub revision: u64, + #[serde(deserialize_with = "deserialize_library_games")] pub games: Vec, } @@ -437,6 +445,7 @@ pub enum CallToPlayAction { pub struct CallToPlayAuthorSnapshot { pub revision: u64, pub display_name: String, + #[serde(deserialize_with = "deserialize_call_to_play_events")] pub events: Vec, } @@ -958,6 +967,85 @@ fn validate_game_id(game_id: &str) -> Result<(), ControlValidationError> { Ok(()) } +fn deserialize_library_games<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserialize_bounded_vec(deserializer, MAX_LIBRARY_GAMES, "library games") +} + +fn deserialize_call_to_play_events<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserialize_bounded_vec( + deserializer, + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR, + "Call to Play author events", + ) +} + +/// Deserializes a wire sequence without ever materialising more than +/// `maximum + 1` elements. +/// +/// The semantic validators (`LibrarySnapshot::validate`, +/// `CallToPlayAuthorSnapshot::validate`) report `TooManyItems` when a +/// collection exceeds its limit, and `Response::decode` deliberately leaves +/// that judgement to the caller so one invalid domain does not discard the +/// other. Truncating to exactly one element past the limit keeps both +/// behaviours intact while bounding the memory an oversize frame can claim +/// during parsing: the remaining elements are consumed as `IgnoredAny` and +/// never allocated. +fn deserialize_bounded_vec<'de, D, T>( + deserializer: D, + maximum: usize, + field: &'static str, +) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct BoundedVecVisitor { + maximum: usize, + field: &'static str, + marker: PhantomData, + } + + impl<'de, T> Visitor<'de> for BoundedVecVisitor + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a bounded sequence of {}", self.field) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element()? { + values.push(value); + if values.len() > self.maximum { + while sequence.next_element::()?.is_some() {} + break; + } + } + Ok(values) + } + } + + deserializer.deserialize_seq(BoundedVecVisitor { + maximum, + field, + marker: PhantomData, + }) +} + fn validate_bounded_chars( value: &str, maximum: usize, @@ -1503,6 +1591,55 @@ mod tests { assert!(Response::decode(Bytes::from(serde_json::to_vec(&value).expect("json"))).is_err()); } + #[test] + fn oversize_wire_collections_are_truncated_to_one_past_their_limit() { + // Build the JSON by hand: the encoder refuses to produce these frames. + let event_json = serde_json::to_string(&event(CallToPlayAction::Rsvp)).expect("event json"); + let events = + std::iter::repeat_n(event_json.as_str(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR * 4) + .collect::>() + .join(","); + let snapshot = format!(r#"{{"revision":1,"display_name":"Alice","events":[{events}]}}"#); + let decoded: CallToPlayAuthorSnapshot = + serde_json::from_str(&snapshot).expect("oversize sequence still parses"); + assert_eq!(decoded.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + 1); + assert!(matches!( + decoded.validate(), + Err(ControlValidationError::TooManyItems { + field: "Call to Play author events", + .. + }) + )); + + let content_id = content(1); + let games = (0..MAX_LIBRARY_GAMES * 4) + .map(|index| format!(r#"{{"game_id":"game-{index:05}","content_id":"{content_id}"}}"#)) + .collect::>() + .join(","); + let library = format!(r#"{{"revision":1,"games":[{games}]}}"#); + let decoded: LibrarySnapshot = + serde_json::from_str(&library).expect("oversize sequence still parses"); + assert_eq!(decoded.games.len(), MAX_LIBRARY_GAMES + 1); + assert!(matches!( + decoded.validate(), + Err(ControlValidationError::TooManyItems { + field: "library games", + .. + }) + )); + + // A sequence exactly at the limit is untouched. + let games = (0..MAX_LIBRARY_GAMES) + .map(|index| format!(r#"{{"game_id":"game-{index:05}","content_id":"{content_id}"}}"#)) + .collect::>() + .join(","); + let decoded: LibrarySnapshot = + serde_json::from_str(&format!(r#"{{"revision":1,"games":[{games}]}}"#)) + .expect("sequence at the limit parses"); + assert_eq!(decoded.games.len(), MAX_LIBRARY_GAMES); + decoded.validate().expect("sequence at the limit is valid"); + } + #[test] fn response_decode_isolates_domain_validation() { let invalid_call_to_play = state_snapshot(vec![ @@ -1518,6 +1655,10 @@ mod tests { panic!("decoded the wrong response variant"); }; decoded.library.validate().expect("library is valid"); + assert_eq!( + decoded.call_to_play.events.len(), + MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR + 1 + ); assert!(decoded.call_to_play.validate().is_err()); assert!( Response::HelloSnapshot(invalid_call_to_play)