Skip to content
This repository was archived by the owner on Jan 27, 2026. It is now read-only.

Commit 7a3df02

Browse files
authored
imp(orchestrator): optimize storage upload routes (#528)
* optimize storage upload routes filename validation
1 parent dd84f2f commit 7a3df02

1 file changed

Lines changed: 129 additions & 7 deletions

File tree

crates/orchestrator/src/api/routes/storage.rs

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ use std::time::Duration;
99

1010
const 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+
1224
async 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

Comments
 (0)