From b6348c29ecc5e749d7e0b13628e63bed88a29bc3 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Thu, 1 Oct 2026 12:46:02 +0200 Subject: [PATCH] ED2KSRV-BUG-001 bound packed-frame decompression --- README.md | 3 +- config/config.toml | 1 + config/config.vps.toml | 1 + src/config.rs | 29 +++++++++ src/proto/frame.rs | 134 +++++++++++++++++++++++++++++++++------ src/server/connection.rs | 5 +- 6 files changed, 153 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 15fb4a6..1fb4531 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,8 @@ a watched file) or needs a **restart**. |---|---|---| | `tcp_port` | eD2k TCP port (default 4661). All UDP ports are derived from it (see below) | **restart** | | `listen_ip` | Bind address for the listeners | restart | -| `listen_backlog`, `max_frame_size` | Socket / frame tuning | restart | +| `listen_backlog`, `max_frame_size` | Socket / wire-frame tuning | restart | +| `max_decompressed_frame_size` | Plaintext ceiling for one packed `0xD4` frame; default 8,000,000 bytes | restart | | `login_timeout_ms` | Login handshake timeout | restart | | `support_crypt` | Advertise protocol obfuscation support | restart | | `hairpin_lan_clients` | Let a client on the server's own network reach HighID (see below). Off by default | live | diff --git a/config/config.toml b/config/config.toml index 49fddbf..0b25478 100644 --- a/config/config.toml +++ b/config/config.toml @@ -38,6 +38,7 @@ tcp_port = 4661 listen_ip = "0.0.0.0" listen_backlog = 256 max_frame_size = 1_000_000 +max_decompressed_frame_size = 8_000_000 login_timeout_ms = 2000 support_crypt = true diff --git a/config/config.vps.toml b/config/config.vps.toml index 065afa8..c9b622e 100644 --- a/config/config.vps.toml +++ b/config/config.vps.toml @@ -51,6 +51,7 @@ tcp_port = 4661 listen_ip = "0.0.0.0" # DO NOT change to your VPS IP — 0.0.0.0 is correct listen_backlog = 1024 max_frame_size = 1_000_000 +max_decompressed_frame_size = 8_000_000 login_timeout_ms = 3000 # Slightly longer for VPS latency support_crypt = true diff --git a/src/config.rs b/src/config.rs index 879d03d..fe276c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -287,6 +287,9 @@ pub struct NetworkConfig { pub listen_backlog: u32, #[serde(default = "default_max_frame")] pub max_frame_size: u32, + /// Maximum plaintext bytes produced by one packed `0xD4` frame. + #[serde(default = "default_max_decompressed_frame")] + pub max_decompressed_frame_size: u32, /// Server key embedded in GLOBSERVSTATRES #[serde(default = "default_udp_server_key")] pub udp_server_key: u32, @@ -635,6 +638,9 @@ fn default_backlog() -> u32 { fn default_max_frame() -> u32 { 1_000_000 } +fn default_max_decompressed_frame() -> u32 { + crate::proto::frame::DEFAULT_MAX_DECOMPRESSED_FRAME_SIZE +} fn default_udp_server_key() -> u32 { 0x1234_5678 } @@ -801,6 +807,9 @@ whitelist_hashes = "" /// Enforce the SPEC.md §1.2 rule: refuse public deployment without /// a hash blocklist configured. pub fn validate(&self) -> Result<()> { + if self.network.max_decompressed_frame_size == 0 { + bail!("network.max_decompressed_frame_size must be at least 1"); + } if self.server.public && self.content_filter.hash_banlist.is_empty() { bail!( "server.public = true requires content_filter.hash_banlist \ @@ -836,6 +845,26 @@ whitelist_hashes = "" mod tests { use super::*; + #[test] + fn decompressed_frame_limit_defaults_to_eight_megabytes() { + let cfg: NetworkConfig = toml::from_str("tcp_port = 4661").unwrap(); + assert_eq!(cfg.max_decompressed_frame_size, 8_000_000); + } + + #[test] + fn decompressed_frame_limit_accepts_an_explicit_override() { + let cfg: NetworkConfig = + toml::from_str("tcp_port = 4661\nmax_decompressed_frame_size = 2_000_000").unwrap(); + assert_eq!(cfg.max_decompressed_frame_size, 2_000_000); + } + + #[test] + fn zero_decompressed_frame_limit_is_invalid() { + let mut cfg = Config::minimal_test_config(); + cfg.network.max_decompressed_frame_size = 0; + assert!(cfg.validate().is_err()); + } + #[test] fn udp_port_is_derived_from_tcp_port() { let cfg = Config::minimal_test_config(); diff --git a/src/proto/frame.rs b/src/proto/frame.rs index 95deefb..a89e751 100644 --- a/src/proto/frame.rs +++ b/src/proto/frame.rs @@ -23,6 +23,9 @@ use super::opcodes::{PROTO_EDONKEY, PROTO_PACKED}; const HEADER_LEN: usize = 6; +/// Default ceiling for plaintext produced from one packed `0xD4` frame. +pub const DEFAULT_MAX_DECOMPRESSED_FRAME_SIZE: u32 = 8_000_000; + #[derive(Debug, Error)] pub enum FrameError { #[error("io: {0}")] @@ -37,6 +40,9 @@ pub enum FrameError { #[error("zlib decompression failed: {0}")] Decompress(String), + #[error("decompressed frame exceeds limit of {limit} bytes")] + DecompressedTooLarge { limit: u32 }, + #[error("zero-length frame")] EmptyFrame, } @@ -56,11 +62,19 @@ impl Frame { pub struct Ed2kCodec { pub max_frame_size: u32, + pub max_decompressed_frame_size: u32, } impl Ed2kCodec { pub fn new(max_frame_size: u32) -> Self { - Self { max_frame_size } + Self::with_limits(max_frame_size, DEFAULT_MAX_DECOMPRESSED_FRAME_SIZE) + } + + pub fn with_limits(max_frame_size: u32, max_decompressed_frame_size: u32) -> Self { + Self { + max_frame_size, + max_decompressed_frame_size, + } } } @@ -113,12 +127,23 @@ impl Decoder for Ed2kCodec { let raw_payload = src.split_to(payload_len); let payload = if proto == PROTO_PACKED { - // zlib-decompress - let mut decoder = flate2::read::ZlibDecoder::new(raw_payload.as_ref()); - let mut out = Vec::with_capacity(payload_len * 2); + // WHY: max_frame_size bounds only compressed wire bytes. A small zlib + // payload can otherwise expand until it exhausts process memory, so + // stop after one byte beyond the configured plaintext ceiling. + let decoder = flate2::read::ZlibDecoder::new(raw_payload.as_ref()); + let initial_capacity = payload_len + .saturating_mul(2) + .min(self.max_decompressed_frame_size as usize); + let mut out = Vec::with_capacity(initial_capacity); decoder + .take(u64::from(self.max_decompressed_frame_size) + 1) .read_to_end(&mut out) .map_err(|e| FrameError::Decompress(e.to_string()))?; + if out.len() > self.max_decompressed_frame_size as usize { + return Err(FrameError::DecompressedTooLarge { + limit: self.max_decompressed_frame_size, + }); + } out } else { raw_payload.to_vec() @@ -222,6 +247,22 @@ impl Encoder for Ed2kCodec { #[cfg(test)] mod tests { use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + + fn packed_frame(plaintext: &[u8]) -> BytesMut { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(plaintext).unwrap(); + let compressed = encoder.finish().unwrap(); + + let mut buf = BytesMut::new(); + buf.put_u8(PROTO_PACKED); + buf.put_u32_le((compressed.len() + 1) as u32); + buf.put_u8(0x33); + buf.put_slice(&compressed); + buf + } #[test] fn round_trip_plain() { @@ -342,25 +383,82 @@ mod tests { #[test] fn decompresses_d4_frame() { - use flate2::write::ZlibEncoder; - use flate2::Compression; - use std::io::Write; - let plaintext = b"compressible compressible compressible"; - let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(plaintext).unwrap(); - let compressed = encoder.finish().unwrap(); - - // Build a D4 frame: marker=D4, length = 1+compressed.len(), opcode=0x33 - let mut buf = BytesMut::new(); - buf.put_u8(PROTO_PACKED); - buf.put_u32_le((compressed.len() + 1) as u32); - buf.put_u8(0x33); - buf.put_slice(&compressed); + let mut buf = packed_frame(plaintext); let mut codec = Ed2kCodec::new(1_000_000); let frame = codec.decode(&mut buf).unwrap().unwrap(); assert_eq!(frame.opcode, 0x33); assert_eq!(frame.payload, plaintext); } + + #[test] + fn accepts_decompressed_payload_at_limit() { + let plaintext = vec![b'A'; 64]; + let mut buf = packed_frame(&plaintext); + let mut codec = Ed2kCodec::with_limits(1_000_000, plaintext.len() as u32); + + let frame = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(frame.payload, plaintext); + } + + #[test] + fn rejects_decompressed_payload_one_byte_over_limit() { + let plaintext = vec![b'A'; 65]; + let mut buf = packed_frame(&plaintext); + let mut codec = Ed2kCodec::with_limits(1_000_000, 64); + + assert!(matches!( + codec.decode(&mut buf), + Err(FrameError::DecompressedTooLarge { limit: 64 }) + )); + } + + #[test] + fn rejects_high_ratio_packed_frame_at_output_limit() { + let plaintext = vec![0u8; 1_000_000]; + let mut buf = packed_frame(&plaintext); + assert!(buf.len() < 2_000, "fixture must exercise high expansion"); + let mut codec = Ed2kCodec::with_limits(1_000_000, 64 * 1024); + + assert!(matches!( + codec.decode(&mut buf), + Err(FrameError::DecompressedTooLarge { limit: 65_536 }) + )); + } + + #[test] + fn rejects_truncated_zlib_stream() { + let mut buf = packed_frame(b"truncated packed frame payload"); + buf.truncate(buf.len() - 6); + let compressed_len = buf.len() - HEADER_LEN; + buf[1..5].copy_from_slice(&((compressed_len + 1) as u32).to_le_bytes()); + let mut codec = Ed2kCodec::with_limits(1_000_000, 1_000_000); + + assert!(matches!( + codec.decode(&mut buf), + Err(FrameError::Decompress(_)) + )); + } + + #[test] + fn compressed_wire_limit_is_checked_before_decompression() { + let mut buf = packed_frame(&vec![b'A'; 1_000]); + let wire_length = u32::from_le_bytes(buf[1..5].try_into().unwrap()); + let mut codec = Ed2kCodec::with_limits(wire_length - 1, 10_000); + + assert!(matches!( + codec.decode(&mut buf), + Err(FrameError::TooLarge { .. }) + )); + } + + #[test] + fn plain_frame_is_not_subject_to_decompressed_limit() { + let mut buf = BytesMut::from(&b"\xE3\x06\x00\x00\x00\x38hello"[..]); + let mut codec = Ed2kCodec::with_limits(1_000_000, 4); + + let frame = codec.decode(&mut buf).unwrap().unwrap(); + assert_eq!(frame.payload, b"hello"); + } } diff --git a/src/server/connection.rs b/src/server/connection.rs index 37f8e43..20a73cc 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -36,7 +36,10 @@ pub async fn handle_connection( // tokio may move between worker threads. load_full() hands back a plain Arc, // which is safe to keep and costs one refcount bump per connection. let live = state.live_cfg.load_full(); - let codec = Ed2kCodec::new(live.network.max_frame_size); + let codec = Ed2kCodec::with_limits( + live.network.max_frame_size, + live.network.max_decompressed_frame_size, + ); // Framed::new would allocate 8 KiB for the read buffer AND 8 KiB for the write // buffer — 16 KiB of heap per connection before a single byte arrives. eD2k // control frames are small (a login, a search, a keepalive: tens to hundreds of