Skip to content

Commit 3b26373

Browse files
author
Greyforge Admin
committed
Validate snapshot storage IDs
1 parent 7954d02 commit 3b26373

2 files changed

Lines changed: 84 additions & 11 deletions

File tree

src/cortex-snapshot/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ pub enum SnapshotError {
2525
Io(#[from] std::io::Error),
2626
#[error("Failed to create snapshot: {0}")]
2727
CreateFailed(String),
28+
#[error("Invalid snapshot storage ID: {0}")]
29+
InvalidId(String),
2830
#[error("Failed to restore snapshot: {0}")]
2931
RestoreFailed(String),
3032
#[error("Git command '{command}' timed out after {timeout_secs}s")]

src/cortex-snapshot/src/storage.rs

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use crate::{Result, RevertPoint, Snapshot, SnapshotError};
44
use serde::{Deserialize, Serialize};
55
use std::collections::HashMap;
6-
use std::path::PathBuf;
6+
use std::path::{Component, Path, PathBuf};
77
use tokio::fs;
88
use tracing::{debug, info};
99

@@ -27,7 +27,7 @@ impl SnapshotStorage {
2727
/// Save a snapshot.
2828
pub async fn save_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
2929
self.init().await?;
30-
let path = self.snapshot_path(&snapshot.id);
30+
let path = self.snapshot_path(&snapshot.id)?;
3131
let json = serde_json::to_string_pretty(snapshot)
3232
.map_err(|e| SnapshotError::CreateFailed(e.to_string()))?;
3333
fs::write(&path, json).await?;
@@ -37,7 +37,7 @@ impl SnapshotStorage {
3737

3838
/// Load a snapshot by ID.
3939
pub async fn load_snapshot(&self, id: &str) -> Result<Snapshot> {
40-
let path = self.snapshot_path(id);
40+
let path = self.snapshot_path(id)?;
4141
let json = fs::read_to_string(&path)
4242
.await
4343
.map_err(|_| SnapshotError::NotFound(id.to_string()))?;
@@ -48,7 +48,7 @@ impl SnapshotStorage {
4848

4949
/// Delete a snapshot.
5050
pub async fn delete_snapshot(&self, id: &str) -> Result<()> {
51-
let path = self.snapshot_path(id);
51+
let path = self.snapshot_path(id)?;
5252
if path.exists() {
5353
fs::remove_file(&path).await?;
5454
debug!("Deleted snapshot: {}", id);
@@ -94,7 +94,7 @@ impl SnapshotStorage {
9494
history: &[RevertPoint],
9595
) -> Result<()> {
9696
self.init().await?;
97-
let path = self.history_path(session_id);
97+
let path = self.history_path(session_id)?;
9898
let json = serde_json::to_string_pretty(history)
9999
.map_err(|e| SnapshotError::CreateFailed(e.to_string()))?;
100100
fs::write(&path, json).await?;
@@ -104,7 +104,7 @@ impl SnapshotStorage {
104104

105105
/// Load session revert history.
106106
pub async fn load_revert_history(&self, session_id: &str) -> Result<Vec<RevertPoint>> {
107-
let path = self.history_path(session_id);
107+
let path = self.history_path(session_id)?;
108108
if !path.exists() {
109109
return Ok(Vec::new());
110110
}
@@ -132,16 +132,34 @@ impl SnapshotStorage {
132132
Ok(removed)
133133
}
134134

135-
fn snapshot_path(&self, id: &str) -> PathBuf {
136-
self.storage_path.join(format!("{}.json", id))
135+
fn snapshot_path(&self, id: &str) -> Result<PathBuf> {
136+
validate_storage_id("snapshot id", id)?;
137+
Ok(self.storage_path.join(format!("{}.json", id)))
137138
}
138139

139-
fn history_path(&self, session_id: &str) -> PathBuf {
140-
self.storage_path
141-
.join(format!("history_{}.json", session_id))
140+
fn history_path(&self, session_id: &str) -> Result<PathBuf> {
141+
validate_storage_id("session id", session_id)?;
142+
Ok(self
143+
.storage_path
144+
.join(format!("history_{}.json", session_id)))
142145
}
143146
}
144147

148+
fn validate_storage_id(kind: &str, id: &str) -> Result<()> {
149+
if id.is_empty()
150+
|| id.chars().any(|c| matches!(c, '/' | '\\' | '\0'))
151+
|| Path::new(id)
152+
.components()
153+
.any(|component| !matches!(component, Component::Normal(_)))
154+
{
155+
return Err(SnapshotError::InvalidId(format!(
156+
"{kind} must be a single filename segment: {id:?}"
157+
)));
158+
}
159+
160+
Ok(())
161+
}
162+
145163
/// Index for fast snapshot lookup.
146164
#[derive(Debug, Default, Serialize, Deserialize)]
147165
pub struct SnapshotIndex {
@@ -203,4 +221,57 @@ mod tests {
203221
assert_eq!(loaded.tree_hash, snapshot.tree_hash);
204222
assert_eq!(loaded.description, snapshot.description);
205223
}
224+
225+
#[tokio::test]
226+
async fn test_snapshot_storage_rejects_traversal_snapshot_id() {
227+
let temp_dir = TempDir::new().unwrap();
228+
let storage = SnapshotStorage::new(temp_dir.path());
229+
230+
let mut snapshot = Snapshot::new("test_hash".to_string());
231+
snapshot.id = "../escaped_snapshot".to_string();
232+
233+
let result = storage.save_snapshot(&snapshot).await;
234+
assert!(matches!(result, Err(SnapshotError::InvalidId(_))));
235+
assert!(!temp_dir.path().join("escaped_snapshot.json").exists());
236+
}
237+
238+
#[tokio::test]
239+
async fn test_revert_history_rejects_traversal_session_id() {
240+
let temp_dir = TempDir::new().unwrap();
241+
let storage = SnapshotStorage::new(temp_dir.path());
242+
storage.init().await.unwrap();
243+
fs::create_dir_all(storage.storage_path.join("history_existing"))
244+
.await
245+
.unwrap();
246+
247+
let result = storage
248+
.save_revert_history("existing/../../escaped_history", &[])
249+
.await;
250+
251+
assert!(matches!(result, Err(SnapshotError::InvalidId(_))));
252+
assert!(!temp_dir.path().join("escaped_history.json").exists());
253+
}
254+
255+
#[test]
256+
fn test_storage_ids_allow_plain_uuid_like_segments() {
257+
assert!(validate_storage_id("snapshot id", "550e8400-e29b-41d4-a716-446655440000").is_ok());
258+
assert!(validate_storage_id("session id", "session_name.1").is_ok());
259+
}
260+
261+
#[test]
262+
fn test_storage_ids_reject_path_segments() {
263+
for id in [
264+
"../escape",
265+
"/absolute",
266+
"nested/path",
267+
r"nested\path",
268+
".",
269+
"..",
270+
] {
271+
assert!(matches!(
272+
validate_storage_id("snapshot id", id),
273+
Err(SnapshotError::InvalidId(_))
274+
));
275+
}
276+
}
206277
}

0 commit comments

Comments
 (0)