Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-validate-upload-output-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

fix(security): validate --upload and --output file paths against traversal
8 changes: 8 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ async fn run() -> Result<(), GwsError> {
.flatten()
.map(|s| s.as_str());

// Validate file paths against traversal before any I/O.
if let Some(p) = upload_path {
crate::validate::validate_safe_file_path(p, "--upload")?;
}
if let Some(p) = output_path {
crate::validate::validate_safe_file_path(p, "--output")?;
}
Comment on lines +221 to +226
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

This validation introduces a Time-of-check to time-of-use (TOCTOU) vulnerability. The validate_safe_file_path function is called, and it correctly canonicalizes the path to prevent traversal. However, the returned safe PathBuf is discarded. The original, potentially unsafe path string is then used later for file I/O in executor::execute_method. An attacker could modify the file system between the check and the use (e.g., by replacing a file with a symlink to a sensitive location) to bypass this validation.

To fix this, you must use the canonicalized path returned from the validation function for all subsequent operations. This will likely require refactoring how upload_path and output_path are defined and passed to executor::execute_method to ensure the sanitized path is used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah TOCTOU is inherent to filesystem checks, but this still blocks the most common attack vector (user-supplied paths with ../ in them). a full fix would need O_NOFOLLOW or similar which is a bigger change. this raises the bar significantly vs no validation at all


let dry_run = matched_args.get_flag("dry-run");

// Build pagination config from flags
Expand Down
109 changes: 109 additions & 0 deletions src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,50 @@ pub fn validate_safe_dir_path(dir: &str) -> Result<PathBuf, GwsError> {
Ok(canonical)
}

/// Validates that a file path (e.g. `--upload` or `--output`) is safe.
///
/// Rejects paths that escape above CWD via `..` traversal, contain
/// control characters, or follow symlinks to locations outside CWD.
/// Absolute paths are allowed for `--upload` (reading an existing file)
/// but the resolved target must not escape CWD for `--output`.
pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result<PathBuf, GwsError> {
reject_control_chars(path_str, flag_name)?;

let path = Path::new(path_str);
let cwd = std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;

let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};

// For existing files, canonicalize to resolve symlinks.
// For non-existing files, normalize the path.
let canonical = if resolved.exists() {
resolved.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to resolve {flag_name} '{}': {e}", path_str))
})?
} else {
normalize_non_existing(&resolved)?
};

let canonical_cwd = cwd.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to canonicalize current directory: {e}"))
})?;

if !canonical.starts_with(&canonical_cwd) {
return Err(GwsError::Validation(format!(
"{flag_name} '{}' resolves to '{}' which is outside the current directory",
path_str,
canonical.display()
)));
}

Ok(canonical)
}

/// Rejects strings containing null bytes or ASCII control characters
/// (including DEL, 0x7F).
fn reject_control_chars(value: &str, flag_name: &str) -> Result<(), GwsError> {
Expand Down Expand Up @@ -566,4 +610,69 @@ mod tests {
fn test_validate_api_identifier_empty() {
assert!(validate_api_identifier("").is_err());
}

// --- validate_safe_file_path ---

#[test]
#[serial]
fn test_file_path_relative_is_ok() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
fs::write(canonical_dir.join("test.txt"), "data").unwrap();

let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();

let result = validate_safe_file_path("test.txt", "--upload");
std::env::set_current_dir(&saved_cwd).unwrap();

assert!(result.is_ok(), "expected Ok, got: {result:?}");
}

#[test]
#[serial]
fn test_file_path_rejects_traversal() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();

let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();

let result = validate_safe_file_path("../../etc/passwd", "--upload");
std::env::set_current_dir(&saved_cwd).unwrap();

assert!(result.is_err(), "path traversal should be rejected");
assert!(
result.unwrap_err().to_string().contains("outside"),
"error should mention 'outside'"
);
}

#[test]
fn test_file_path_rejects_control_chars() {
let result = validate_safe_file_path("file\x00.txt", "--output");
assert!(result.is_err(), "null bytes should be rejected");
}

#[test]
#[serial]
fn test_file_path_rejects_symlink_escape() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();

// Create a symlink that points outside the directory
#[cfg(unix)]
{
let link_path = canonical_dir.join("escape");
std::os::unix::fs::symlink("/tmp", &link_path).unwrap();

let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();

let result = validate_safe_file_path("escape/secret.txt", "--output");
std::env::set_current_dir(&saved_cwd).unwrap();

assert!(result.is_err(), "symlink escape should be rejected");
}
}
}
Loading