Skip to content

Commit b241a5b

Browse files
jpoehneltjpoehnelt-botgoogleworkspace-bot
authored
fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths (#523)
* fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths - Cap Retry-After header at 60s to prevent hostile servers from hanging the CLI - Extract compute_retry_delay() with saturating_pow for safe exponential backoff - Sanitize mimeType by stripping control characters to prevent MIME header injection - Add validate_safe_file_path() for --upload and --output path validation - Gate --upload/--output through path validation in main.rs before any I/O Consolidates security fixes from PRs #448 and #447. * chore: regenerate skills [skip ci] * fix: resolve mimeType sanitization bypass and document TOCTOU caveat - Restructure resolve_upload_mime() using or_else chain so all code paths go through control-char stripping (early returns were bypassing it) - Document TOCTOU limitation in validate_safe_file_path() as known caveat * fix: use canonicalized paths for I/O and normalize .. in non-existent suffix Address review comments: - main.rs: use canonicalized path from validate_safe_file_path for I/O instead of discarding it (closes TOCTOU gap) - validate.rs: add normalize_dotdot() to resolve .. components in non-existent suffix (prevents traversal via doesnt_exist/../../etc/passwd) - Add regression test for non-existent prefix traversal bypass --------- Co-authored-by: jpoehnelt-bot <[email protected]> Co-authored-by: googleworkspace-bot <[email protected]>
1 parent 398e80c commit b241a5b

File tree

5 files changed

+271
-29
lines changed

5 files changed

+271
-29
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths

src/client.rs

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
2020
}
2121

2222
const MAX_RETRIES: u32 = 3;
23+
/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile
24+
/// or misconfigured server from hanging the process indefinitely.
25+
const MAX_RETRY_DELAY_SECS: u64 = 60;
2326

2427
/// Send an HTTP request with automatic retry on 429 (rate limit) responses.
2528
/// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s).
@@ -33,20 +36,11 @@ pub async fn send_with_retry(
3336
return Ok(resp);
3437
}
3538

36-
// Parse Retry-After header (seconds), fall back to exponential backoff
37-
let retry_after = resp
39+
let header_value = resp
3840
.headers()
3941
.get("retry-after")
40-
.and_then(|v| v.to_str().ok())
41-
.and_then(|s| s.parse::<u64>().ok())
42-
.unwrap_or(1 << attempt); // 1, 2, 4 seconds
43-
44-
tracing::debug!(
45-
attempt = attempt + 1,
46-
max_retries = MAX_RETRIES,
47-
retry_after_secs = retry_after,
48-
"Rate limited, retrying"
49-
);
42+
.and_then(|v| v.to_str().ok());
43+
let retry_after = compute_retry_delay(header_value, attempt);
5044

5145
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
5246
}
@@ -55,6 +49,16 @@ pub async fn send_with_retry(
5549
build_request().send().await
5650
}
5751

52+
/// Compute the retry delay from a Retry-After header value and attempt number.
53+
/// Falls back to exponential backoff (1, 2, 4s) when the header is absent or
54+
/// unparseable. Always caps the result at MAX_RETRY_DELAY_SECS.
55+
fn compute_retry_delay(header_value: Option<&str>, attempt: u32) -> u64 {
56+
header_value
57+
.and_then(|s| s.parse::<u64>().ok())
58+
.unwrap_or(2u64.saturating_pow(attempt))
59+
.min(MAX_RETRY_DELAY_SECS)
60+
}
61+
5862
#[cfg(test)]
5963
mod tests {
6064
use super::*;
@@ -63,4 +67,33 @@ mod tests {
6367
fn build_client_succeeds() {
6468
assert!(build_client().is_ok());
6569
}
70+
71+
#[test]
72+
fn retry_delay_caps_large_header_value() {
73+
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);
74+
}
75+
76+
#[test]
77+
fn retry_delay_passes_through_small_header_value() {
78+
assert_eq!(compute_retry_delay(Some("5"), 0), 5);
79+
}
80+
81+
#[test]
82+
fn retry_delay_falls_back_to_exponential_on_missing_header() {
83+
assert_eq!(compute_retry_delay(None, 0), 1); // 2^0
84+
assert_eq!(compute_retry_delay(None, 1), 2); // 2^1
85+
assert_eq!(compute_retry_delay(None, 2), 4); // 2^2
86+
}
87+
88+
#[test]
89+
fn retry_delay_falls_back_on_unparseable_header() {
90+
assert_eq!(compute_retry_delay(Some("not-a-number"), 1), 2);
91+
assert_eq!(compute_retry_delay(Some(""), 0), 1);
92+
}
93+
94+
#[test]
95+
fn retry_delay_caps_at_boundary() {
96+
assert_eq!(compute_retry_delay(Some("60"), 0), 60);
97+
assert_eq!(compute_retry_delay(Some("61"), 0), MAX_RETRY_DELAY_SECS);
98+
}
6699
}

src/executor.rs

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -798,30 +798,37 @@ fn handle_error_response<T>(
798798
/// represents the *source* type (what the bytes are). When a user uploads
799799
/// `notes.md` with `"mimeType":"application/vnd.google-apps.document"`, the
800800
/// media part should be `text/markdown`, not a Google Workspace MIME type.
801+
///
802+
/// All returned MIME types have control characters stripped to prevent
803+
/// MIME header injection via user-controlled metadata.
801804
fn resolve_upload_mime(
802805
explicit: Option<&str>,
803806
upload_path: Option<&str>,
804807
metadata: &Option<Value>,
805808
) -> String {
806-
if let Some(mime) = explicit {
807-
return mime.to_string();
808-
}
809-
810-
if let Some(path) = upload_path {
811-
if let Some(detected) = mime_from_extension(path) {
812-
return detected.to_string();
813-
}
814-
}
809+
let raw = explicit
810+
.map(|s| s.to_string())
811+
.or_else(|| {
812+
upload_path
813+
.and_then(mime_from_extension)
814+
.map(|s| s.to_string())
815+
})
816+
.or_else(|| {
817+
metadata
818+
.as_ref()
819+
.and_then(|m| m.get("mimeType"))
820+
.and_then(|v| v.as_str())
821+
.map(|s| s.to_string())
822+
})
823+
.unwrap_or_else(|| "application/octet-stream".to_string());
815824

816-
if let Some(mime) = metadata
817-
.as_ref()
818-
.and_then(|m| m.get("mimeType"))
819-
.and_then(|v| v.as_str())
820-
{
821-
return mime.to_string();
825+
// Strip CR/LF and other control characters to prevent MIME header injection.
826+
let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect();
827+
if sanitized.is_empty() {
828+
"application/octet-stream".to_string()
829+
} else {
830+
sanitized
822831
}
823-
824-
"application/octet-stream".to_string()
825832
}
826833

827834
/// Infers a MIME type from a file path's extension.
@@ -1459,6 +1466,28 @@ mod tests {
14591466
);
14601467
}
14611468

1469+
#[test]
1470+
fn test_resolve_upload_mime_sanitizes_crlf_injection() {
1471+
// A malicious mimeType with CRLF should be stripped to prevent
1472+
// MIME header injection in the multipart body.
1473+
let metadata = Some(json!({
1474+
"mimeType": "text/plain\r\nX-Injected: malicious"
1475+
}));
1476+
let mime = resolve_upload_mime(None, None, &metadata);
1477+
assert!(
1478+
!mime.contains('\r') && !mime.contains('\n'),
1479+
"control characters must be stripped: got '{mime}'"
1480+
);
1481+
assert_eq!(mime, "text/plainX-Injected: malicious");
1482+
}
1483+
1484+
#[test]
1485+
fn test_resolve_upload_mime_all_control_chars_fallback() {
1486+
let metadata = Some(json!({ "mimeType": "\r\n\t" }));
1487+
let mime = resolve_upload_mime(None, None, &metadata);
1488+
assert_eq!(mime, "application/octet-stream");
1489+
}
1490+
14621491
#[tokio::test]
14631492
async fn test_build_multipart_stream_content_length() {
14641493
let dir = tempfile::tempdir().unwrap();

src/main.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,22 @@ async fn run() -> Result<(), GwsError> {
227227
.flatten()
228228
.map(|s| s.as_str());
229229

230+
// Validate file paths against traversal before any I/O.
231+
// Use the returned canonical paths so the validated path is the one
232+
// actually used for I/O (closes TOCTOU gap).
233+
let upload_path_buf = if let Some(p) = upload_path {
234+
Some(crate::validate::validate_safe_file_path(p, "--upload")?)
235+
} else {
236+
None
237+
};
238+
let output_path_buf = if let Some(p) = output_path {
239+
Some(crate::validate::validate_safe_file_path(p, "--output")?)
240+
} else {
241+
None
242+
};
243+
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
244+
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());
245+
230246
let dry_run = matched_args.get_flag("dry-run");
231247

232248
// Build pagination config from flags

src/validate.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,79 @@ pub fn validate_safe_dir_path(dir: &str) -> Result<PathBuf, GwsError> {
137137
Ok(canonical)
138138
}
139139

140+
/// Validates that a file path (e.g. `--upload` or `--output`) is safe.
141+
///
142+
/// Rejects paths that escape above CWD via `..` traversal, contain
143+
/// control characters, or follow symlinks to locations outside CWD.
144+
/// Absolute paths are allowed (reading an existing file from a known
145+
/// location is legitimate) but the resolved target must still live
146+
/// under CWD.
147+
///
148+
/// # TOCTOU caveat
149+
///
150+
/// This is a best-effort defence-in-depth check. A local attacker with
151+
/// write access to a parent directory could replace a path component
152+
/// between this validation and the subsequent I/O. Fully eliminating
153+
/// TOCTOU would require `openat(O_NOFOLLOW)` on each path component,
154+
/// which is tracked as a follow-up for Unix platforms.
155+
pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result<PathBuf, GwsError> {
156+
reject_control_chars(path_str, flag_name)?;
157+
158+
let path = Path::new(path_str);
159+
let cwd = std::env::current_dir()
160+
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;
161+
162+
let resolved = if path.is_absolute() {
163+
path.to_path_buf()
164+
} else {
165+
cwd.join(path)
166+
};
167+
168+
// For existing files, canonicalize to resolve symlinks.
169+
// For non-existing files, get the prefix canonicalized then normalize
170+
// the remaining components to resolve any `..` or `.` segments.
171+
let canonical = if resolved.exists() {
172+
resolved.canonicalize().map_err(|e| {
173+
GwsError::Validation(format!("Failed to resolve {flag_name} '{}': {e}", path_str))
174+
})?
175+
} else {
176+
let raw = normalize_non_existing(&resolved)?;
177+
// normalize_non_existing does NOT resolve `..` in the non-existent
178+
// suffix. We must resolve them here to prevent bypass via paths like
179+
// `non_existent/../../etc/passwd`.
180+
normalize_dotdot(&raw)
181+
};
182+
183+
let canonical_cwd = cwd.canonicalize().map_err(|e| {
184+
GwsError::Validation(format!("Failed to canonicalize current directory: {e}"))
185+
})?;
186+
187+
if !canonical.starts_with(&canonical_cwd) {
188+
return Err(GwsError::Validation(format!(
189+
"{flag_name} '{}' resolves to '{}' which is outside the current directory",
190+
path_str,
191+
canonical.display()
192+
)));
193+
}
194+
195+
Ok(canonical)
196+
}
197+
198+
/// Resolve `.` and `..` components in a path without touching the filesystem.
199+
fn normalize_dotdot(path: &Path) -> PathBuf {
200+
let mut out = PathBuf::new();
201+
for component in path.components() {
202+
match component {
203+
std::path::Component::ParentDir => {
204+
out.pop();
205+
}
206+
std::path::Component::CurDir => {}
207+
c => out.push(c),
208+
}
209+
}
210+
out
211+
}
212+
140213
/// Rejects strings containing null bytes, ASCII control characters
141214
/// (including DEL, 0x7F), or dangerous Unicode characters such as
142215
/// zero-width chars, bidi overrides, and Unicode line/paragraph separators.
@@ -701,4 +774,90 @@ mod tests {
701774
fn test_validate_api_identifier_empty() {
702775
assert!(validate_api_identifier("").is_err());
703776
}
777+
778+
// --- validate_safe_file_path ---
779+
780+
#[test]
781+
#[serial]
782+
fn test_file_path_relative_is_ok() {
783+
let dir = tempdir().unwrap();
784+
let canonical_dir = dir.path().canonicalize().unwrap();
785+
fs::write(canonical_dir.join("test.txt"), "data").unwrap();
786+
787+
let saved_cwd = std::env::current_dir().unwrap();
788+
std::env::set_current_dir(&canonical_dir).unwrap();
789+
790+
let result = validate_safe_file_path("test.txt", "--upload");
791+
std::env::set_current_dir(&saved_cwd).unwrap();
792+
793+
assert!(result.is_ok(), "expected Ok, got: {result:?}");
794+
}
795+
796+
#[test]
797+
#[serial]
798+
fn test_file_path_rejects_traversal() {
799+
let dir = tempdir().unwrap();
800+
let canonical_dir = dir.path().canonicalize().unwrap();
801+
802+
let saved_cwd = std::env::current_dir().unwrap();
803+
std::env::set_current_dir(&canonical_dir).unwrap();
804+
805+
let result = validate_safe_file_path("../../etc/passwd", "--upload");
806+
std::env::set_current_dir(&saved_cwd).unwrap();
807+
808+
assert!(result.is_err(), "path traversal should be rejected");
809+
assert!(
810+
result.unwrap_err().to_string().contains("outside"),
811+
"error should mention 'outside'"
812+
);
813+
}
814+
815+
#[test]
816+
fn test_file_path_rejects_control_chars() {
817+
let result = validate_safe_file_path("file\x00.txt", "--output");
818+
assert!(result.is_err(), "null bytes should be rejected");
819+
}
820+
821+
#[test]
822+
#[serial]
823+
fn test_file_path_rejects_symlink_escape() {
824+
let dir = tempdir().unwrap();
825+
let canonical_dir = dir.path().canonicalize().unwrap();
826+
827+
// Create a symlink that points outside the directory
828+
#[cfg(unix)]
829+
{
830+
let link_path = canonical_dir.join("escape");
831+
std::os::unix::fs::symlink("/tmp", &link_path).unwrap();
832+
833+
let saved_cwd = std::env::current_dir().unwrap();
834+
std::env::set_current_dir(&canonical_dir).unwrap();
835+
836+
let result = validate_safe_file_path("escape/secret.txt", "--output");
837+
std::env::set_current_dir(&saved_cwd).unwrap();
838+
839+
assert!(result.is_err(), "symlink escape should be rejected");
840+
}
841+
}
842+
843+
#[test]
844+
#[serial]
845+
fn test_file_path_rejects_traversal_via_nonexistent_prefix() {
846+
// Regression: non_existent/../../etc/passwd could bypass starts_with
847+
// because normalize_non_existing preserves ".." in the non-existent
848+
// suffix. The normalize_dotdot fix resolves this.
849+
let dir = tempdir().unwrap();
850+
let canonical_dir = dir.path().canonicalize().unwrap();
851+
852+
let saved_cwd = std::env::current_dir().unwrap();
853+
std::env::set_current_dir(&canonical_dir).unwrap();
854+
855+
let result = validate_safe_file_path("doesnt_exist/../../etc/passwd", "--output");
856+
std::env::set_current_dir(&saved_cwd).unwrap();
857+
858+
assert!(
859+
result.is_err(),
860+
"traversal via non-existent prefix should be rejected"
861+
);
862+
}
704863
}

0 commit comments

Comments
 (0)