Skip to content

Commit 6eaa5ad

Browse files
committed
fix(security): harden upload/output file IO against TOCTOU races
1 parent 3c3ffbf commit 6eaa5ad

File tree

4 files changed

+449
-22
lines changed

4 files changed

+449
-22
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ keyring = "3.6.3"
5656
async-trait = "0.1.89"
5757
serde_yaml = "0.9.34"
5858
percent-encoding = "2.3.2"
59+
libc = "0.2"
5960

6061

6162
# The profile that 'cargo dist' will build with

src/executor.rs

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ async fn build_http_request(
144144
auth_method: &AuthMethod,
145145
page_token: Option<&str>,
146146
pages_fetched: u32,
147-
upload_path: Option<&str>,
147+
upload_bytes: Option<&[u8]>,
148148
) -> Result<reqwest::RequestBuilder, GwsError> {
149149
let mut request = match method.http_method.as_str() {
150150
"GET" => client.get(&input.full_url),
@@ -180,17 +180,11 @@ async fn build_http_request(
180180

181181
if pages_fetched == 0 {
182182
if input.is_upload {
183-
let upload_path = upload_path.expect("upload_path must be Some when is_upload is true");
184-
185-
let file_bytes = tokio::fs::read(upload_path).await.map_err(|e| {
186-
GwsError::Validation(format!(
187-
"Failed to read upload file '{}': {}",
188-
upload_path, e
189-
))
190-
})?;
183+
let file_bytes =
184+
upload_bytes.expect("upload bytes must be Some when is_upload is true");
191185

192186
request = request.query(&[("uploadType", "multipart")]);
193-
let (multipart_body, content_type) = build_multipart_body(&input.body, &file_bytes)?;
187+
let (multipart_body, content_type) = build_multipart_body(&input.body, file_bytes)?;
194188
request = request.header("Content-Type", content_type);
195189
request = request.body(multipart_body);
196190
} else if let Some(ref body_val) = input.body {
@@ -307,17 +301,27 @@ async fn handle_binary_response(
307301
output_format: &crate::formatter::OutputFormat,
308302
capture_output: bool,
309303
) -> Result<Option<Value>, GwsError> {
310-
let file_path = if let Some(p) = output_path {
311-
PathBuf::from(p)
304+
let (mut file, file_path) = if let Some(path) = output_path {
305+
let path_owned = path.to_string();
306+
let (file, canonical_path) = tokio::task::spawn_blocking(move || {
307+
crate::validate::open_safe_output_file(&path_owned)
308+
})
309+
.await
310+
.map_err(|e| {
311+
GwsError::Other(anyhow::anyhow!(
312+
"Output file open/validation task failed: {e}"
313+
))
314+
})??;
315+
(tokio::fs::File::from_std(file), canonical_path)
312316
} else {
313317
let ext = mime_to_extension(content_type);
314-
PathBuf::from(format!("download.{ext}"))
318+
let file_path = PathBuf::from(format!("download.{ext}"));
319+
let file = tokio::fs::File::create(&file_path)
320+
.await
321+
.context("Failed to create output file")?;
322+
(file, file_path)
315323
};
316324

317-
let mut file = tokio::fs::File::create(&file_path)
318-
.await
319-
.context("Failed to create output file")?;
320-
321325
let mut stream = response.bytes_stream();
322326
let mut total_bytes: u64 = 0;
323327

@@ -377,24 +381,24 @@ pub async fn execute_method(
377381
// Validate untrusted filesystem paths before any network or file I/O.
378382
let safe_output_path = if let Some(path) = output_path {
379383
let path_owned = path.to_string();
380-
let validated = tokio::task::spawn_blocking(move || {
384+
tokio::task::spawn_blocking(move || {
381385
crate::validate::validate_safe_output_file_path(&path_owned)
382386
})
383387
.await
384388
.map_err(|e| GwsError::Other(anyhow::anyhow!("Path validation task failed: {e}")))??;
385-
Some(validated.to_string_lossy().into_owned())
389+
Some(path.to_string())
386390
} else {
387391
None
388392
};
389393

390394
let safe_upload_path = if let Some(path) = upload_path {
391395
let path_owned = path.to_string();
392-
let validated = tokio::task::spawn_blocking(move || {
396+
tokio::task::spawn_blocking(move || {
393397
crate::validate::validate_safe_upload_file_path(&path_owned)
394398
})
395399
.await
396400
.map_err(|e| GwsError::Other(anyhow::anyhow!("Path validation task failed: {e}")))??;
397-
Some(validated.to_string_lossy().into_owned())
401+
Some(path.to_string())
398402
} else {
399403
None
400404
};
@@ -426,6 +430,21 @@ pub async fn execute_method(
426430
return Ok(None);
427431
}
428432

433+
let upload_bytes = if input.is_upload {
434+
let upload_path = safe_upload_path
435+
.as_deref()
436+
.expect("safe_upload_path must be Some when is_upload is true")
437+
.to_string();
438+
let bytes = tokio::task::spawn_blocking(move || {
439+
crate::validate::read_safe_upload_file(&upload_path)
440+
})
441+
.await
442+
.map_err(|e| GwsError::Other(anyhow::anyhow!("Upload file read task failed: {e}")))??;
443+
Some(bytes)
444+
} else {
445+
None
446+
};
447+
429448
let mut page_token: Option<String> = None;
430449
let mut pages_fetched: u32 = 0;
431450
let mut captured_values = Vec::new();
@@ -440,7 +459,7 @@ pub async fn execute_method(
440459
&auth_method,
441460
page_token.as_deref(),
442461
pages_fetched,
443-
safe_upload_path.as_deref(),
462+
upload_bytes.as_deref(),
444463
)
445464
.await?;
446465

@@ -2071,3 +2090,47 @@ async fn test_get_does_not_set_content_length_zero() {
20712090
"GET with no body should not have Content-Length header"
20722091
);
20732092
}
2093+
2094+
#[tokio::test]
2095+
async fn test_build_http_request_upload_uses_provided_bytes() {
2096+
let client = reqwest::Client::new();
2097+
let method = RestMethod {
2098+
http_method: "POST".to_string(),
2099+
path: "files".to_string(),
2100+
supports_media_upload: true,
2101+
..Default::default()
2102+
};
2103+
let input = ExecutionInput {
2104+
full_url: "https://example.com/files".to_string(),
2105+
body: Some(json!({"name": "x.txt"})),
2106+
params: Map::new(),
2107+
query_params: HashMap::new(),
2108+
is_upload: true,
2109+
};
2110+
2111+
let request = build_http_request(
2112+
&client,
2113+
&method,
2114+
&input,
2115+
None,
2116+
&AuthMethod::None,
2117+
None,
2118+
0,
2119+
Some(b"hello"),
2120+
)
2121+
.await
2122+
.unwrap();
2123+
2124+
let built = request.build().unwrap();
2125+
assert!(built
2126+
.url()
2127+
.query()
2128+
.unwrap_or_default()
2129+
.contains("uploadType=multipart"));
2130+
let content_type = built
2131+
.headers()
2132+
.get("Content-Type")
2133+
.and_then(|v| v.to_str().ok())
2134+
.unwrap_or_default();
2135+
assert!(content_type.starts_with("multipart/related; boundary="));
2136+
}

0 commit comments

Comments
 (0)