commit b9f813fb4b7de1042370b529b9ccc036b208465b from: murilo ijanc date: Wed Mar 25 17:22:21 2026 UTC Fix critical data integrity and security issues - Atomic writes in store (write-to-temp + rename) to prevent corruption on crash - Validate DHT results against requested content hash to reject forged data from malicious nodes - Limit protocol line size to 128 KiB on Unix socket to prevent memory exhaustion - Use saturating_add for TTL expiry to prevent u64 overflow commit - 57176d45cacb98f1968daa8f8b2efd2735da2731 commit + b9f813fb4b7de1042370b529b9ccc036b208465b blob - 313a4aa4f065a48741d91ffc8571f831898a4ff6 blob + c578e5c402a0b0258a157edccf97b8984e5ba8df --- src/daemon.rs +++ src/daemon.rs @@ -6,7 +6,7 @@ //! over a Unix socket using a line-oriented text protocol //! (see [`crate::protocol`]). -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; @@ -196,7 +196,7 @@ fn republish(node: &mut Node, store: &PasteStore) { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let expires = paste.created_at + paste.ttl_secs; + let expires = paste.created_at.saturating_add(paste.ttl_secs); let rem = expires.saturating_sub(now); std::cmp::min(rem, u16::MAX as u64) as u16 }; @@ -259,6 +259,10 @@ pub fn run_unix_listener( let _ = std::fs::remove_file(sock_path); } +/// Maximum protocol line size (128 KiB covers the 64 KiB paste +/// limit after base58 expansion plus command overhead). +const MAX_LINE_SIZE: usize = 128 * 1024; + /// Read requests line-by-line from a connected Unix socket /// client, forwarding each to the daemon main loop via `tx`. fn handle_client( @@ -268,11 +272,29 @@ fn handle_client( stream.set_nonblocking(false)?; stream.set_read_timeout(Some(Duration::from_secs(60)))?; - let reader = BufReader::new(&stream); + let mut reader = BufReader::new(&stream); let mut writer = &stream; + let mut line = String::new(); - for line in reader.lines() { - let line = line?; + loop { + line.clear(); + // Limit read to MAX_LINE_SIZE to prevent a client from + // exhausting memory with an unbounded request line. + let n = (&mut reader).take(MAX_LINE_SIZE as u64).read_line(&mut line)?; + if n == 0 { + break; + } + if !line.ends_with('\n') && n >= MAX_LINE_SIZE { + let resp = protocol::format_response(&Response::Err( + "request too large".into(), + )); + writer.write_all(resp.as_bytes())?; + // Drain remaining bytes until newline + let mut discard = String::new(); + let _ = reader.read_line(&mut discard); + continue; + } + let line = line.trim(); let cmd = match protocol::parse_request(&line) { Ok(c) => c, Err(e) => { blob - 302bd5893e29d915799aaae103fbcb444edfa327 blob + 45fb919d90f70615db9b181e4c17eebe0fcc696d --- src/ops.rs +++ src/ops.rs @@ -118,7 +118,17 @@ pub fn get_paste( if vals.is_empty() { return Err(PasteError::NotFound); } - vals[0].clone() + // Verify DHT result: the content hash must match the + // requested key to prevent a malicious node from + // injecting arbitrary data. + match vals.iter().find(|v| { + Paste::from_bytes(v) + .map(|p| Paste::content_key(&p.content) == *hash) + .unwrap_or(false) + }) { + Some(v) => v.clone(), + None => return Err(PasteError::NotFound), + } }; let paste = Paste::from_bytes(&data).ok_or(PasteError::InvalidKey)?; blob - 50b32b17e3b00963bb84e866831305096f51d354 blob + 8bfe9792c1bc71763b585a56873a8a23890fb059 --- src/paste.rs +++ src/paste.rs @@ -81,7 +81,7 @@ impl Paste { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - now > self.created_at + self.ttl_secs + now > self.created_at.saturating_add(self.ttl_secs) } } blob - 18a7641ac411d880b5f3701315fc6a049084a4bb blob + 98c54817e574fabaf0b4650ad6da24831a411efc --- src/store.rs +++ src/store.rs @@ -45,14 +45,12 @@ impl PasteStore { // ── Paste CRUD ────────────────────────────────── - /// Write a paste to disk. The key (32 bytes) is prepended - /// to the file so [`original_keys`] can reconstruct it. + /// Write a paste to disk atomically (write-to-temp + rename). + /// The key (32 bytes) is prepended to the file so + /// [`original_keys`] can reconstruct it. pub fn put_paste(&self, key: &[u8], value: &[u8]) -> std::io::Result<()> { let path = self.paste_path(key); - let mut f = fs::File::create(path)?; - f.write_all(key)?; - f.write_all(value)?; - Ok(()) + atomic_write(&path, &[key, value]) } /// Read a paste from disk. Returns `None` if the paste @@ -179,6 +177,20 @@ impl PasteStore { } } +/// Write data to `path` atomically: write to a temporary file in +/// the same directory, then rename over the target. This prevents +/// corruption if the process is killed mid-write. +fn atomic_write(path: &Path, chunks: &[&[u8]]) -> std::io::Result<()> { + let parent = path.parent().unwrap_or(Path::new(".")); + let tmp = parent.join(format!(".tmp.{}", std::process::id())); + let mut f = fs::File::create(&tmp)?; + for chunk in chunks { + f.write_all(chunk)?; + } + f.sync_all()?; + fs::rename(&tmp, path) +} + // ── tesseras-dht persistence traits ───────────────── impl tesseras_dht::persist::RoutingPersistence for PasteStore { @@ -198,7 +210,7 @@ impl tesseras_dht::persist::RoutingPersistence for Pas buf.extend_from_slice(id); buf.extend_from_slice(addr_bytes); } - fs::write(&path, &buf).map_err(tesseras_dht::Error::Io)?; + atomic_write(&path, &[&buf]).map_err(tesseras_dht::Error::Io)?; log::info!("store: persisted {} routing contacts", contacts.len()); Ok(()) }