22
33use crate :: { Result , RevertPoint , Snapshot , SnapshotError } ;
44use serde:: { Deserialize , Serialize } ;
5+ use sha2:: { Digest , Sha256 } ;
56use std:: collections:: HashMap ;
67use std:: path:: PathBuf ;
78use 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