Skip to content

Commit bed9f6e

Browse files
authored
Reject non-ULID IDs in cache actionlog read command. (#2499)
1 parent a3b7bf7 commit bed9f6e

4 files changed

Lines changed: 105 additions & 30 deletions

File tree

cmd/soroban-cli/src/commands/cache/actionlog/read.rs

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::{fs, io, path::PathBuf};
1+
use std::io;
22

33
use crate::config::{data, locator};
44

@@ -10,8 +10,10 @@ pub enum Error {
1010
Data(#[from] data::Error),
1111
#[error("failed to find cache entry {0}")]
1212
NotFound(String),
13+
#[error("invalid cache entry ID \"{0}\": expected a ULID")]
14+
InvalidId(String),
1315
#[error(transparent)]
14-
SerdeJson(#[from] serde_json::Error),
16+
Io(#[from] std::io::Error),
1517
}
1618

1719
#[derive(Debug, clap::Parser, Clone)]
@@ -24,15 +26,72 @@ pub struct Cmd {
2426

2527
impl Cmd {
2628
pub fn run(&self) -> Result<(), Error> {
27-
let file = self.file()?;
29+
let id: ulid::Ulid = self
30+
.id
31+
.parse()
32+
.map_err(|_| Error::InvalidId(self.id.clone()))?;
33+
let file = data::actions_dir()?
34+
.join(id.to_string())
35+
.with_extension("json");
2836
tracing::debug!("reading file {}", file.display());
29-
let mut file = fs::File::open(file).map_err(|_| Error::NotFound(self.id.clone()))?;
30-
let mut stdout = io::stdout();
31-
let _ = io::copy(&mut file, &mut stdout);
37+
let mut f = std::fs::File::open(&file).map_err(|e| {
38+
if e.kind() == io::ErrorKind::NotFound {
39+
Error::NotFound(self.id.clone())
40+
} else {
41+
Error::Io(e)
42+
}
43+
})?;
44+
io::copy(&mut f, &mut io::stdout())?;
3245
Ok(())
3346
}
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
use crate::test_utils::EnvGuard;
53+
use serial_test::serial;
54+
55+
#[test]
56+
#[serial]
57+
fn path_traversal_via_dotdot_is_rejected() {
58+
let tmp = tempfile::tempdir().unwrap();
59+
let _guard = EnvGuard::set("STELLAR_DATA_HOME", tmp.path());
60+
61+
let outside = tmp.path().join("outside.json");
62+
std::fs::write(&outside, r#"{"leaked":true}"#).unwrap();
63+
64+
let cmd = Cmd {
65+
id: "../outside".to_string(),
66+
};
67+
68+
let err = cmd.run().expect_err("expected error for path-traversal ID");
69+
assert!(
70+
matches!(err, Error::InvalidId(_)),
71+
"expected InvalidId, got {err:?}"
72+
);
73+
}
74+
75+
#[test]
76+
#[serial]
77+
fn absolute_path_id_is_rejected() {
78+
let tmp = tempfile::tempdir().unwrap();
79+
let _guard = EnvGuard::set("STELLAR_DATA_HOME", tmp.path());
80+
81+
let outside = tmp.path().join("outside.json");
82+
std::fs::write(&outside, r#"{"leaked":true}"#).unwrap();
83+
84+
let abs_id = outside
85+
.to_str()
86+
.unwrap()
87+
.trim_end_matches(".json")
88+
.to_string();
89+
let cmd = Cmd { id: abs_id };
3490

35-
pub fn file(&self) -> Result<PathBuf, Error> {
36-
Ok(data::actions_dir()?.join(&self.id).with_extension("json"))
91+
let err = cmd.run().expect_err("expected error for absolute-path ID");
92+
assert!(
93+
matches!(err, Error::InvalidId(_)),
94+
"expected InvalidId, got {err:?}"
95+
);
3796
}
3897
}

cmd/soroban-cli/src/config/locator.rs

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -888,28 +888,7 @@ mod tests {
888888
);
889889
}
890890

891-
struct EnvGuard(Vec<(String, Option<String>)>);
892-
893-
impl EnvGuard {
894-
fn new(vars: &[&str]) -> Self {
895-
let saved = vars
896-
.iter()
897-
.map(|k| (k.to_string(), std::env::var(k).ok()))
898-
.collect();
899-
Self(saved)
900-
}
901-
}
902-
903-
impl Drop for EnvGuard {
904-
fn drop(&mut self) {
905-
for (k, v) in &self.0 {
906-
match v {
907-
Some(val) => std::env::set_var(k, val),
908-
None => std::env::remove_var(k),
909-
}
910-
}
911-
}
912-
}
891+
use crate::test_utils::EnvGuard;
913892

914893
#[test]
915894
#[serial]

cmd/soroban-cli/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub mod upgrade_check;
2727
pub mod utils;
2828
pub mod wasm;
2929

30+
#[cfg(test)]
31+
pub mod test_utils;
32+
3033
pub use commands::Root;
3134

3235
pub fn parse_cmd<T>(s: &str) -> Result<T, clap::Error>

cmd/soroban-cli/src/test_utils.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/// Saves and restores environment variables on drop.
2+
///
3+
/// Useful in tests that mutate env vars — guarantees cleanup even on panic.
4+
pub struct EnvGuard(Vec<(String, Option<String>)>);
5+
6+
impl EnvGuard {
7+
pub fn new(vars: &[&str]) -> Self {
8+
let saved = vars
9+
.iter()
10+
.map(|k| (k.to_string(), std::env::var(k).ok()))
11+
.collect();
12+
for k in vars {
13+
std::env::remove_var(k);
14+
}
15+
Self(saved)
16+
}
17+
18+
pub fn set(key: &'static str, val: &std::path::Path) -> Self {
19+
let prev = std::env::var(key).ok();
20+
std::env::set_var(key, val);
21+
Self(vec![(key.to_string(), prev)])
22+
}
23+
}
24+
25+
impl Drop for EnvGuard {
26+
fn drop(&mut self) {
27+
for (k, v) in &self.0 {
28+
match v {
29+
Some(val) => std::env::set_var(k, val),
30+
None => std::env::remove_var(k),
31+
}
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)