Skip to content

Commit ff965a6

Browse files
committed
fix(snapshot): hash storage ids for file paths
1 parent 7954d02 commit ff965a6

1 file changed

Lines changed: 184 additions & 13 deletions

File tree

src/cortex-snapshot/src/storage.rs

Lines changed: 184 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use crate::{Result, RevertPoint, Snapshot, SnapshotError};
44
use serde::{Deserialize, Serialize};
5+
use sha2::{Digest, Sha256};
56
use std::collections::HashMap;
67
use std::path::PathBuf;
78
use tokio::fs;
@@ -37,8 +38,8 @@ impl SnapshotStorage {
3738

3839
/// Load a snapshot by ID.
3940
pub async fn load_snapshot(&self, id: &str) -> Result<Snapshot> {
40-
let path = self.snapshot_path(id);
41-
let json = fs::read_to_string(&path)
41+
let json = self
42+
.read_first_existing(self.snapshot_paths(id))
4243
.await
4344
.map_err(|_| SnapshotError::NotFound(id.to_string()))?;
4445
let snapshot: Snapshot =
@@ -48,10 +49,11 @@ impl SnapshotStorage {
4849

4950
/// Delete a snapshot.
5051
pub async fn delete_snapshot(&self, id: &str) -> Result<()> {
51-
let path = self.snapshot_path(id);
52-
if path.exists() {
53-
fs::remove_file(&path).await?;
54-
debug!("Deleted snapshot: {}", id);
52+
for path in self.snapshot_paths(id) {
53+
if path.exists() {
54+
fs::remove_file(&path).await?;
55+
debug!("Deleted snapshot: {}", id);
56+
}
5557
}
5658
Ok(())
5759
}
@@ -104,11 +106,15 @@ impl SnapshotStorage {
104106

105107
/// Load session revert history.
106108
pub async fn load_revert_history(&self, session_id: &str) -> Result<Vec<RevertPoint>> {
107-
let path = self.history_path(session_id);
108-
if !path.exists() {
109-
return Ok(Vec::new());
110-
}
111-
let json = fs::read_to_string(&path).await?;
109+
let json = match self
110+
.read_first_existing(self.history_paths(session_id))
111+
.await
112+
{
113+
Ok(json) => json,
114+
Err(_) => {
115+
return Ok(Vec::new());
116+
}
117+
};
112118
let history: Vec<RevertPoint> =
113119
serde_json::from_str(&json).map_err(|e| SnapshotError::NotFound(e.to_string()))?;
114120
Ok(history)
@@ -132,13 +138,76 @@ impl SnapshotStorage {
132138
Ok(removed)
133139
}
134140

141+
async fn read_first_existing(&self, paths: Vec<PathBuf>) -> std::io::Result<String> {
142+
for path in paths {
143+
if path.exists() {
144+
return fs::read_to_string(&path).await;
145+
}
146+
}
147+
Err(std::io::Error::new(
148+
std::io::ErrorKind::NotFound,
149+
"file not found",
150+
))
151+
}
152+
153+
fn snapshot_paths(&self, id: &str) -> Vec<PathBuf> {
154+
let mut paths = vec![self.snapshot_path(id)];
155+
if let Some(path) = self.legacy_snapshot_path(id) {
156+
paths.push(path);
157+
}
158+
paths
159+
}
160+
161+
fn history_paths(&self, session_id: &str) -> Vec<PathBuf> {
162+
let mut paths = vec![self.history_path(session_id)];
163+
if let Some(path) = self.legacy_history_path(session_id) {
164+
paths.push(path);
165+
}
166+
paths
167+
}
168+
135169
fn snapshot_path(&self, id: &str) -> PathBuf {
136-
self.storage_path.join(format!("{}.json", id))
170+
self.storage_path
171+
.join(format!("snapshot_{}.json", Self::hashed_id(id)))
137172
}
138173

139174
fn history_path(&self, session_id: &str) -> PathBuf {
140175
self.storage_path
141-
.join(format!("history_{}.json", session_id))
176+
.join(format!("history_{}.json", Self::hashed_id(session_id)))
177+
}
178+
179+
fn legacy_snapshot_path(&self, id: &str) -> Option<PathBuf> {
180+
if Self::is_safe_legacy_id(id) {
181+
Some(self.storage_path.join(format!("{}.json", id)))
182+
} else {
183+
None
184+
}
185+
}
186+
187+
fn legacy_history_path(&self, session_id: &str) -> Option<PathBuf> {
188+
if Self::is_safe_legacy_id(session_id) {
189+
Some(
190+
self.storage_path
191+
.join(format!("history_{}.json", session_id)),
192+
)
193+
} else {
194+
None
195+
}
196+
}
197+
198+
fn hashed_id(id: &str) -> String {
199+
let mut hasher = Sha256::new();
200+
hasher.update(id.as_bytes());
201+
hex::encode(hasher.finalize())
202+
}
203+
204+
fn is_safe_legacy_id(id: &str) -> bool {
205+
if id.is_empty() {
206+
return false;
207+
}
208+
209+
id.chars()
210+
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
142211
}
143212
}
144213

@@ -203,4 +272,106 @@ mod tests {
203272
assert_eq!(loaded.tree_hash, snapshot.tree_hash);
204273
assert_eq!(loaded.description, snapshot.description);
205274
}
275+
276+
#[test]
277+
fn snapshot_paths_hash_untrusted_ids_into_storage_dir() {
278+
let temp_dir = TempDir::new().unwrap();
279+
let storage = SnapshotStorage::new(temp_dir.path());
280+
let path = storage.snapshot_path("../escape/owned");
281+
282+
assert_eq!(path.parent(), Some(storage.storage_path.as_path()));
283+
284+
let filename = path.file_name().unwrap().to_string_lossy();
285+
assert!(filename.starts_with("snapshot_"));
286+
assert!(filename.ends_with(".json"));
287+
assert!(!filename.contains(".."));
288+
assert!(!filename.contains('/'));
289+
assert!(!filename.contains('\\'));
290+
}
291+
292+
#[test]
293+
fn history_paths_hash_untrusted_ids_into_storage_dir() {
294+
let temp_dir = TempDir::new().unwrap();
295+
let storage = SnapshotStorage::new(temp_dir.path());
296+
let path = storage.history_path("session/../../escape/owned");
297+
298+
assert_eq!(path.parent(), Some(storage.storage_path.as_path()));
299+
300+
let filename = path.file_name().unwrap().to_string_lossy();
301+
assert!(filename.starts_with("history_"));
302+
assert!(filename.ends_with(".json"));
303+
assert!(!filename.contains(".."));
304+
assert!(!filename.contains('/'));
305+
assert!(!filename.contains('\\'));
306+
}
307+
308+
#[tokio::test]
309+
async fn save_snapshot_with_path_components_does_not_escape_storage_dir() {
310+
let temp_dir = TempDir::new().unwrap();
311+
let storage = SnapshotStorage::new(temp_dir.path());
312+
let escape_dir = temp_dir.path().join("escape");
313+
fs::create_dir_all(&escape_dir).await.unwrap();
314+
315+
let mut snapshot = Snapshot::new("test_hash".to_string());
316+
snapshot.id = "../escape/owned".to_string();
317+
318+
storage.save_snapshot(&snapshot).await.unwrap();
319+
320+
assert!(!escape_dir.join("owned.json").exists());
321+
assert!(storage.snapshot_path(&snapshot.id).exists());
322+
323+
let loaded = storage.load_snapshot(&snapshot.id).await.unwrap();
324+
assert_eq!(loaded.id, snapshot.id);
325+
}
326+
327+
#[tokio::test]
328+
async fn load_snapshot_with_path_components_ignores_legacy_traversal_path() {
329+
let temp_dir = TempDir::new().unwrap();
330+
let storage = SnapshotStorage::new(temp_dir.path());
331+
let escape_dir = temp_dir.path().join("escape");
332+
fs::create_dir_all(&escape_dir).await.unwrap();
333+
334+
let mut snapshot = Snapshot::new("test_hash".to_string());
335+
snapshot.id = "../escape/owned".to_string();
336+
let json = serde_json::to_string(&snapshot).unwrap();
337+
fs::write(escape_dir.join("owned.json"), json)
338+
.await
339+
.unwrap();
340+
341+
assert!(storage.load_snapshot(&snapshot.id).await.is_err());
342+
}
343+
344+
#[tokio::test]
345+
async fn save_history_with_path_components_does_not_escape_storage_dir() {
346+
let temp_dir = TempDir::new().unwrap();
347+
let storage = SnapshotStorage::new(temp_dir.path());
348+
let escape_dir = temp_dir.path().join("escape");
349+
fs::create_dir_all(&escape_dir).await.unwrap();
350+
351+
let session_id = "session/../../escape/owned";
352+
353+
storage.save_revert_history(session_id, &[]).await.unwrap();
354+
355+
assert!(!escape_dir.join("owned.json").exists());
356+
assert!(storage.history_path(session_id).exists());
357+
358+
let loaded = storage.load_revert_history(session_id).await.unwrap();
359+
assert!(loaded.is_empty());
360+
}
361+
362+
#[tokio::test]
363+
async fn load_history_with_path_components_ignores_legacy_traversal_path() {
364+
let temp_dir = TempDir::new().unwrap();
365+
let storage = SnapshotStorage::new(temp_dir.path());
366+
let escape_dir = temp_dir.path().join("escape");
367+
fs::create_dir_all(&escape_dir).await.unwrap();
368+
369+
let session_id = "session/../../escape/owned";
370+
fs::write(escape_dir.join("owned.json"), "[]")
371+
.await
372+
.unwrap();
373+
374+
let loaded = storage.load_revert_history(session_id).await.unwrap();
375+
assert!(loaded.is_empty());
376+
}
206377
}

0 commit comments

Comments
 (0)