@@ -9,6 +9,18 @@ use std::time::Duration;
99
1010const MAX_FILE_SIZE : u64 = 100 * 1024 * 1024 ;
1111
12+ fn validate_file_name ( file_name : & str ) -> Result < String , String > {
13+ if file_name. trim ( ) . is_empty ( ) {
14+ return Err ( "File name cannot be empty" . to_string ( ) ) ;
15+ }
16+
17+ if file_name. contains ( '\0' ) {
18+ return Err ( "File name cannot contain null bytes" . to_string ( ) ) ;
19+ }
20+
21+ Ok ( file_name. to_string ( ) )
22+ }
23+
1224async fn request_upload (
1325 req : HttpRequest ,
1426 request_upload : web:: Json < RequestUploadRequest > ,
@@ -22,6 +34,16 @@ async fn request_upload(
2234 } ) ) ;
2335 }
2436
37+ let validated_file_name = match validate_file_name ( & request_upload. file_name ) {
38+ Ok ( name) => name,
39+ Err ( error) => {
40+ return HttpResponse :: BadRequest ( ) . json ( serde_json:: json!( {
41+ "success" : false ,
42+ "error" : format!( "Invalid file name: {}" , error)
43+ } ) ) ;
44+ }
45+ } ;
46+
2547 let mut redis_con = match app_state
2648 . redis_store
2749 . client
@@ -102,12 +124,12 @@ async fn request_upload(
102124
103125 let storage_config = task. storage_config ;
104126
105- let mut file_name = request_upload . file_name . to_string ( ) ;
127+ let mut file_name = validated_file_name . clone ( ) ;
106128 let mut group_id = None ;
107129
108130 if let Some ( storage_config) = storage_config {
109131 if let Some ( file_name_template) = storage_config. file_name_template {
110- file_name = generate_file_name ( & file_name_template, & request_upload . file_name ) ;
132+ file_name = generate_file_name ( & file_name_template, & validated_file_name ) ;
111133
112134 // TODO: This is a temporary integration of node groups plugin functionality.
113135 // We have a plan to move this to proper expander traits that will handle
@@ -148,11 +170,8 @@ async fn request_upload(
148170
149171 // Create a unique key for this file upload based on address, group_id, and file name
150172 let upload_key = match & group_id {
151- Some ( gid) => format ! ( "upload:{}:{}:{}" , address, gid, & request_upload. file_name) ,
152- None => format ! (
153- "upload:{}:{}:{}" ,
154- address, "no-group" , & request_upload. file_name
155- ) ,
173+ Some ( gid) => format ! ( "upload:{}:{}:{}" , address, gid, & validated_file_name) ,
174+ None => format ! ( "upload:{}:{}:{}" , address, "no-group" , & validated_file_name) ,
156175 } ;
157176 let upload_exists: Result < Option < String > , redis:: RedisError > = redis_con. get ( & upload_key) . await ;
158177 if let Ok ( None ) = upload_exists {
@@ -329,6 +348,30 @@ mod tests {
329348 use shared:: models:: task:: { SchedulingConfig , StorageConfig , Task } ;
330349 use uuid:: Uuid ;
331350
351+ #[ tokio:: test]
352+ async fn test_validate_file_name ( ) {
353+ assert_eq ! ( validate_file_name( "test.txt" ) . unwrap( ) , "test.txt" ) ;
354+ assert_eq ! ( validate_file_name( "file.parquet" ) . unwrap( ) , "file.parquet" ) ;
355+ assert_eq ! (
356+ validate_file_name( "some/path/file.txt" ) . unwrap( ) ,
357+ "some/path/file.txt"
358+ ) ;
359+
360+ // These are now allowed since GCS handles them safely
361+ assert_eq ! (
362+ validate_file_name( "../../../etc/passwd" ) . unwrap( ) ,
363+ "../../../etc/passwd"
364+ ) ;
365+ assert_eq ! ( validate_file_name( "/etc/passwd" ) . unwrap( ) , "/etc/passwd" ) ;
366+
367+ // Null bytes still blocked
368+ assert ! ( validate_file_name( "test.txt\0 .exe" ) . is_err( ) ) ;
369+
370+ // Empty names still blocked
371+ assert ! ( validate_file_name( "" ) . is_err( ) ) ;
372+ assert ! ( validate_file_name( " " ) . is_err( ) ) ;
373+ }
374+
332375 #[ tokio:: test]
333376 async fn test_generate_file_name ( ) {
334377 let template = "test/${ORIGINAL_NAME}" ;
@@ -392,6 +435,85 @@ mod tests {
392435 assert ! ( metrics. contains( & format!( "orchestrator_file_upload_requests_total{{node_address=\" test_address\" ,pool_id=\" {}\" ,task_id=\" {}\" ,task_name=\" test-task\" }} 1" , app_state. metrics. pool_id, task. id) ) ) ;
393436 }
394437
438+ #[ actix_web:: test]
439+ async fn test_request_upload_input_validation ( ) {
440+ let app_state = create_test_app_state ( ) . await ;
441+
442+ let task = Task {
443+ id : Uuid :: new_v4 ( ) ,
444+ image : "test-image" . to_string ( ) ,
445+ name : "test-task" . to_string ( ) ,
446+ storage_config : Some ( StorageConfig {
447+ file_name_template : Some ( "model_123/user_uploads/${ORIGINAL_NAME}" . to_string ( ) ) ,
448+ } ) ,
449+ ..Default :: default ( )
450+ } ;
451+
452+ let task_store = app_state. store_context . task_store . clone ( ) ;
453+ let _ = task_store. add_task ( task. clone ( ) ) . await ;
454+
455+ let app =
456+ test:: init_service ( App :: new ( ) . app_data ( app_state. clone ( ) ) . service (
457+ web:: scope ( "/storage" ) . route ( "/request-upload" , post ( ) . to ( request_upload) ) ,
458+ ) )
459+ . await ;
460+
461+ // Test null byte injection (still blocked)
462+ let req = test:: TestRequest :: post ( )
463+ . uri ( "/storage/request-upload" )
464+ . insert_header ( ( "x-address" , "test_address" ) )
465+ . set_json ( & RequestUploadRequest {
466+ file_name : "test.txt\0 .exe" . to_string ( ) ,
467+ file_size : 1024 ,
468+ file_type : "text/plain" . to_string ( ) ,
469+ sha256 : "test_sha256" . to_string ( ) ,
470+ task_id : task. id . to_string ( ) ,
471+ } )
472+ . to_request ( ) ;
473+
474+ let resp = test:: call_service ( & app, req) . await ;
475+ let body = test:: read_body ( resp) . await ;
476+ let json: serde_json:: Value = serde_json:: from_slice ( & body) . unwrap ( ) ;
477+ assert_eq ! ( json[ "success" ] , serde_json:: Value :: Bool ( false ) ) ;
478+ assert ! ( json[ "error" ] . as_str( ) . unwrap( ) . contains( "null bytes" ) ) ;
479+
480+ // Test various file names (now allowed)
481+ let test_cases = vec ! [
482+ (
483+ "../../../etc/passwd" ,
484+ "model_123/user_uploads/../../../etc/passwd" ,
485+ ) ,
486+ ( "/etc/passwd" , "model_123/user_uploads//etc/passwd" ) ,
487+ (
488+ "some/path/test.txt" ,
489+ "model_123/user_uploads/some/path/test.txt" ,
490+ ) ,
491+ ] ;
492+
493+ for ( input, expected) in test_cases {
494+ let req = test:: TestRequest :: post ( )
495+ . uri ( "/storage/request-upload" )
496+ . insert_header ( ( "x-address" , "test_address" ) )
497+ . set_json ( & RequestUploadRequest {
498+ file_name : input. to_string ( ) ,
499+ file_size : 1024 ,
500+ file_type : "text/plain" . to_string ( ) ,
501+ sha256 : "test_sha256" . to_string ( ) ,
502+ task_id : task. id . to_string ( ) ,
503+ } )
504+ . to_request ( ) ;
505+
506+ let resp = test:: call_service ( & app, req) . await ;
507+ let body = test:: read_body ( resp) . await ;
508+ let json: serde_json:: Value = serde_json:: from_slice ( & body) . unwrap ( ) ;
509+ assert_eq ! ( json[ "success" ] , serde_json:: Value :: Bool ( true ) ) ;
510+ assert_eq ! (
511+ json[ "file_name" ] ,
512+ serde_json:: Value :: String ( expected. to_string( ) )
513+ ) ;
514+ }
515+ }
516+
395517 #[ actix_web:: test]
396518 async fn test_request_upload_invalid_task ( ) {
397519 let app_state = create_test_app_state ( ) . await ;
0 commit comments