diff --git a/cmd/soroban-cli/src/commands/cache/actionlog/read.rs b/cmd/soroban-cli/src/commands/cache/actionlog/read.rs index 4991ac6ef..88307853a 100644 --- a/cmd/soroban-cli/src/commands/cache/actionlog/read.rs +++ b/cmd/soroban-cli/src/commands/cache/actionlog/read.rs @@ -1,4 +1,4 @@ -use std::{fs, io, path::PathBuf}; +use std::io; use crate::config::{data, locator}; @@ -12,6 +12,10 @@ pub enum Error { NotFound(String), #[error(transparent)] SerdeJson(#[from] serde_json::Error), + #[error("invalid cache entry ID \"{0}\": expected a ULID")] + InvalidId(String), + #[error(transparent)] + Io(#[from] std::io::Error), } #[derive(Debug, clap::Parser, Clone)] @@ -24,15 +28,69 @@ pub struct Cmd { impl Cmd { pub fn run(&self) -> Result<(), Error> { - let file = self.file()?; + let id: ulid::Ulid = self + .id + .parse() + .map_err(|_| Error::InvalidId(self.id.clone()))?; + let file = data::actions_dir()? + .join(id.to_string()) + .with_extension("json"); tracing::debug!("reading file {}", file.display()); - let mut file = fs::File::open(file).map_err(|_| Error::NotFound(self.id.clone()))?; - let mut stdout = io::stdout(); - let _ = io::copy(&mut file, &mut stdout); + let mut f = std::fs::File::open(&file).map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + Error::NotFound(self.id.clone()) + } else { + Error::Io(e) + } + })?; + io::copy(&mut f, &mut io::stdout())?; Ok(()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn path_traversal_via_dotdot_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("STELLAR_DATA_HOME", tmp.path()); + + let outside = tmp.path().join("outside.json"); + std::fs::write(&outside, r#"{"leaked":true}"#).unwrap(); + + let cmd = Cmd { + id: "../outside".to_string(), + }; + + assert!( + cmd.run().is_err(), + "expected an error for a path-traversal ID, but run() succeeded" + ); + } + + #[test] + #[serial] + fn absolute_path_id_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("STELLAR_DATA_HOME", tmp.path()); + + let outside = tmp.path().join("outside.json"); + std::fs::write(&outside, r#"{"leaked":true}"#).unwrap(); + + let abs_id = outside + .to_str() + .unwrap() + .trim_end_matches(".json") + .to_string(); + let cmd = Cmd { id: abs_id }; - pub fn file(&self) -> Result { - Ok(data::actions_dir()?.join(&self.id).with_extension("json")) + assert!( + cmd.run().is_err(), + "expected an error for an absolute-path ID, but run() succeeded" + ); } }