fix(peer): bound inbound request frames at 64 KiB instead of 8 MiB
Security audit findings NET-03 and Codex #6 ("control-frame prefixes can reserve about 512 MiB across concurrent decoders"). Both directions of the control plane shared MAX_CONTROL_FRAME_BYTES (8 MiB). That size exists for responses: a HelloSnapshot with 4096 library games and a maximal Call-to-Play author slice legitimately approaches it. Requests are tiny; the largest possible GetGameFileChunk with a 255-byte game ID and a 900-byte catalog path is under 2 KiB. Yet every anonymous inbound stream was decoded with an 8 MiB LengthDelimitedCodec, and tokio-util reserves the declared frame length as soon as the 4-byte prefix arrives. With 64 global control-stream permits a LAN host could make a responder reserve ~512 MiB by sending nothing but length prefixes. Changes: - lanspread-proto gains MAX_REQUEST_FRAME_BYTES (64 KiB). Request encode/decode enforce it in addition to the shared bound; Response keeps the 8 MiB allowance. - The server-side stream handler decodes inbound frames with a request-sized codec. The response writer is unchanged. - The server QUIC limits shrink the per-stream receive window to one request frame and size the connection window so every one of the 32 allowed streams can hold its allowance (2 MiB per connection instead of 8 MiB per stream). Client-side decoders (network.rs, discovery Hello pulls) still use the 8 MiB bound because they read responses from identity-pinned peers. Test plan: `just test` (proto tests assert the exact limits and that a maximal request encodes far below the bound; stream tests assert the inbound codec uses the request bound). Manual: three peer-cli containers still exchange snapshots and complete downloads. Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
This commit is contained in:
3 files changed
+83
-9
No files matched your search
@@ -10,7 +10,13 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwn
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 8;
|
||||
pub const ALPN_PROTOCOL: &[u8] = b"lanspread/8";
|
||||
/// Upper bound for one control-plane frame in either direction. Responses
|
||||
/// (peer-state snapshots) can legitimately approach this size.
|
||||
pub const MAX_CONTROL_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||
/// Upper bound for one inbound `Request` frame. Every request variant is a
|
||||
/// few kilobytes at most, so public responders decode requests with this much
|
||||
/// smaller allowance instead of reserving a snapshot-sized buffer per stream.
|
||||
pub const MAX_REQUEST_FRAME_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_STREAM_INSTALL_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const MAX_LIBRARY_GAMES: usize = 4_096;
|
||||
pub const MAX_GAME_ID_BYTES: usize = 255;
|
||||
@@ -713,11 +719,14 @@ trait ValidateControlMessage {
|
||||
|
||||
impl ControlMessage for Request {
|
||||
fn decode(bytes: Bytes) -> Result<Self, ControlCodecError> {
|
||||
check_request_frame_length(bytes.len())?;
|
||||
decode_control_message(&bytes)
|
||||
}
|
||||
|
||||
fn encode(&self) -> Result<Bytes, ControlCodecError> {
|
||||
encode_control_message(self)
|
||||
let bytes = encode_control_message(self)?;
|
||||
check_request_frame_length(bytes.len())?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +781,16 @@ const fn check_control_frame_length(length: usize) -> Result<(), ControlCodecErr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn check_request_frame_length(length: usize) -> Result<(), ControlCodecError> {
|
||||
if length > MAX_REQUEST_FRAME_BYTES {
|
||||
return Err(ControlCodecError::FrameTooLarge {
|
||||
actual: length,
|
||||
maximum: MAX_REQUEST_FRAME_BYTES,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ValidateControlMessage for Request {
|
||||
fn validate_control(&self) -> Result<(), ControlValidationError> {
|
||||
match self {
|
||||
@@ -1180,9 +1199,33 @@ mod tests {
|
||||
assert_eq!(PROTOCOL_VERSION, 8);
|
||||
assert_eq!(ALPN_PROTOCOL, b"lanspread/8");
|
||||
assert_eq!(MAX_CONTROL_FRAME_BYTES, 8 * 1024 * 1024);
|
||||
assert_eq!(MAX_REQUEST_FRAME_BYTES, 64 * 1024);
|
||||
assert_eq!(MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES, 4 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximal_requests_fit_the_request_frame_bound() {
|
||||
use lanspread_db::content_manifest::{MAX_CATALOG_COMPONENT_BYTES, MAX_CATALOG_PATH_BYTES};
|
||||
|
||||
let directory = "d".repeat(MAX_CATALOG_COMPONENT_BYTES);
|
||||
let relative_path = format!(
|
||||
"{directory}/{directory}/{directory}/{}",
|
||||
"f".repeat(MAX_CATALOG_PATH_BYTES - 3 * (MAX_CATALOG_COMPONENT_BYTES + 1))
|
||||
);
|
||||
assert_eq!(relative_path.len(), MAX_CATALOG_PATH_BYTES);
|
||||
let request = Request::GetGameFileChunk {
|
||||
game_id: "g".repeat(MAX_GAME_ID_BYTES),
|
||||
content_id: content(0xff),
|
||||
relative_path: CanonicalCatalogPath::new(relative_path)
|
||||
.expect("maximal catalog path should be canonical"),
|
||||
offset: u64::MAX,
|
||||
length: u64::MAX,
|
||||
};
|
||||
let encoded = request.encode().expect("maximal request must encode");
|
||||
assert!(encoded.len() < MAX_REQUEST_FRAME_BYTES / 8);
|
||||
Request::decode(encoded).expect("maximal request must decode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_hex_ids_are_canonical_and_distinct() {
|
||||
let runtime = session(0xab);
|
||||
@@ -1673,11 +1716,20 @@ mod tests {
|
||||
#[test]
|
||||
fn control_frame_limit_and_single_document_are_enforced() {
|
||||
assert!(matches!(
|
||||
Request::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES])),
|
||||
Request::decode(Bytes::from(vec![b' '; MAX_REQUEST_FRAME_BYTES])),
|
||||
Err(ControlCodecError::Decode(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Request::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES + 1])),
|
||||
Request::decode(Bytes::from(vec![b' '; MAX_REQUEST_FRAME_BYTES + 1])),
|
||||
Err(ControlCodecError::FrameTooLarge { actual, maximum })
|
||||
if actual == MAX_REQUEST_FRAME_BYTES + 1 && maximum == MAX_REQUEST_FRAME_BYTES
|
||||
));
|
||||
assert!(matches!(
|
||||
Response::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES])),
|
||||
Err(ControlCodecError::Decode(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
Response::decode(Bytes::from(vec![b' '; MAX_CONTROL_FRAME_BYTES + 1])),
|
||||
Err(ControlCodecError::FrameTooLarge { actual, maximum })
|
||||
if actual == MAX_CONTROL_FRAME_BYTES + 1 && maximum == MAX_CONTROL_FRAME_BYTES
|
||||
));
|
||||
|
||||
Reference in new issue
Block a user