Skip to content
Merged
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/security-hardening-rollup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths
4 changes: 3 additions & 1 deletion skills/gws-calendar-insert/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,20 @@ gws calendar +insert --summary <TEXT> --start <TIME> --end <TIME>
| `--location` | — | — | Event location |
| `--description` | — | — | Event description/body |
| `--attendee` | — | — | Attendee email (can be used multiple times) |
| `--meet` | — | — | Add a Google Meet video conference link |

## Examples

```bash
gws calendar +insert --summary 'Standup' --start '2026-06-17T09:00:00-07:00' --end '2026-06-17T09:30:00-07:00'
gws calendar +insert --summary 'Review' --start ... --end ... --attendee alice@example.com
gws calendar +insert --summary 'Meet' --start ... --end ... --meet
```

## Tips

- Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
- For recurring events or conference links, use the raw API instead.
- The --meet flag automatically adds a Google Meet link to the event.

> [!CAUTION]
> This is a **write** command — confirm with the user before executing.
Expand Down
57 changes: 45 additions & 12 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
}

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

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

// Parse Retry-After header (seconds), fall back to exponential backoff
let retry_after = resp
let header_value = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(1 << attempt); // 1, 2, 4 seconds

tracing::debug!(
attempt = attempt + 1,
max_retries = MAX_RETRIES,
retry_after_secs = retry_after,
"Rate limited, retrying"
);
.and_then(|v| v.to_str().ok());
let retry_after = compute_retry_delay(header_value, attempt);

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

/// Compute the retry delay from a Retry-After header value and attempt number.
/// Falls back to exponential backoff (1, 2, 4s) when the header is absent or
/// unparseable. Always caps the result at MAX_RETRY_DELAY_SECS.
fn compute_retry_delay(header_value: Option<&str>, attempt: u32) -> u64 {
header_value
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(2u64.saturating_pow(attempt))
.min(MAX_RETRY_DELAY_SECS)
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -63,4 +67,33 @@ mod tests {
fn build_client_succeeds() {
assert!(build_client().is_ok());
}

#[test]
fn retry_delay_caps_large_header_value() {
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);
}

#[test]
fn retry_delay_passes_through_small_header_value() {
assert_eq!(compute_retry_delay(Some("5"), 0), 5);
}

#[test]
fn retry_delay_falls_back_to_exponential_on_missing_header() {
assert_eq!(compute_retry_delay(None, 0), 1); // 2^0
assert_eq!(compute_retry_delay(None, 1), 2); // 2^1
assert_eq!(compute_retry_delay(None, 2), 4); // 2^2
}

#[test]
fn retry_delay_falls_back_on_unparseable_header() {
assert_eq!(compute_retry_delay(Some("not-a-number"), 1), 2);
assert_eq!(compute_retry_delay(Some(""), 0), 1);
}

#[test]
fn retry_delay_caps_at_boundary() {
assert_eq!(compute_retry_delay(Some("60"), 0), 60);
assert_eq!(compute_retry_delay(Some("61"), 0), MAX_RETRY_DELAY_SECS);
}
}
63 changes: 46 additions & 17 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -798,30 +798,37 @@ fn handle_error_response<T>(
/// represents the *source* type (what the bytes are). When a user uploads
/// `notes.md` with `"mimeType":"application/vnd.google-apps.document"`, the
/// media part should be `text/markdown`, not a Google Workspace MIME type.
///
/// All returned MIME types have control characters stripped to prevent
/// MIME header injection via user-controlled metadata.
fn resolve_upload_mime(
explicit: Option<&str>,
upload_path: Option<&str>,
metadata: &Option<Value>,
) -> String {
if let Some(mime) = explicit {
return mime.to_string();
}

if let Some(path) = upload_path {
if let Some(detected) = mime_from_extension(path) {
return detected.to_string();
}
}
let raw = explicit
.map(|s| s.to_string())
.or_else(|| {
upload_path
.and_then(mime_from_extension)
.map(|s| s.to_string())
})
.or_else(|| {
metadata
.as_ref()
.and_then(|m| m.get("mimeType"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "application/octet-stream".to_string());

if let Some(mime) = metadata
.as_ref()
.and_then(|m| m.get("mimeType"))
.and_then(|v| v.as_str())
{
return mime.to_string();
// Strip CR/LF and other control characters to prevent MIME header injection.
let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect();
if sanitized.is_empty() {
"application/octet-stream".to_string()
} else {
sanitized
}

"application/octet-stream".to_string()
}

/// Infers a MIME type from a file path's extension.
Expand Down Expand Up @@ -1459,6 +1466,28 @@ mod tests {
);
}

#[test]
fn test_resolve_upload_mime_sanitizes_crlf_injection() {
// A malicious mimeType with CRLF should be stripped to prevent
// MIME header injection in the multipart body.
let metadata = Some(json!({
"mimeType": "text/plain\r\nX-Injected: malicious"
}));
let mime = resolve_upload_mime(None, None, &metadata);
assert!(
!mime.contains('\r') && !mime.contains('\n'),
"control characters must be stripped: got '{mime}'"
);
assert_eq!(mime, "text/plainX-Injected: malicious");
}

#[test]
fn test_resolve_upload_mime_all_control_chars_fallback() {
let metadata = Some(json!({ "mimeType": "\r\n\t" }));
let mime = resolve_upload_mime(None, None, &metadata);
assert_eq!(mime, "application/octet-stream");
}

#[tokio::test]
async fn test_build_multipart_stream_content_length() {
let dir = tempfile::tempdir().unwrap();
Expand Down
16 changes: 16 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,22 @@ async fn run() -> Result<(), GwsError> {
.flatten()
.map(|s| s.as_str());

// Validate file paths against traversal before any I/O.
// Use the returned canonical paths so the validated path is the one
// actually used for I/O (closes TOCTOU gap).
let upload_path_buf = if let Some(p) = upload_path {
Some(crate::validate::validate_safe_file_path(p, "--upload")?)
} else {
None
};
let output_path_buf = if let Some(p) = output_path {
Some(crate::validate::validate_safe_file_path(p, "--output")?)
} else {
None
};
let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str());
let output_path = output_path_buf.as_deref().and_then(|p| p.to_str());

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

// Build pagination config from flags
Expand Down
159 changes: 159 additions & 0 deletions src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,79 @@ 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 (reading an existing file from a known
/// location is legitimate) but the resolved target must still live
/// under CWD.
///
/// # TOCTOU caveat
///
/// This is a best-effort defence-in-depth check. A local attacker with
/// write access to a parent directory could replace a path component
/// between this validation and the subsequent I/O. Fully eliminating
/// TOCTOU would require `openat(O_NOFOLLOW)` on each path component,
/// which is tracked as a follow-up for Unix platforms.
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, get the prefix canonicalized then normalize
// the remaining components to resolve any `..` or `.` segments.
let canonical = if resolved.exists() {
resolved.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to resolve {flag_name} '{}': {e}", path_str))
})?
} else {
let raw = normalize_non_existing(&resolved)?;
// normalize_non_existing does NOT resolve `..` in the non-existent
// suffix. We must resolve them here to prevent bypass via paths like
// `non_existent/../../etc/passwd`.
normalize_dotdot(&raw)
};

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)
}

/// Resolve `.` and `..` components in a path without touching the filesystem.
fn normalize_dotdot(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
c => out.push(c),
}
}
out
}

/// Rejects strings containing null bytes, ASCII control characters
/// (including DEL, 0x7F), or dangerous Unicode characters such as
/// zero-width chars, bidi overrides, and Unicode line/paragraph separators.
Expand Down Expand Up @@ -701,4 +774,90 @@ 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");
}
}

#[test]
#[serial]
fn test_file_path_rejects_traversal_via_nonexistent_prefix() {
// Regression: non_existent/../../etc/passwd could bypass starts_with
// because normalize_non_existing preserves ".." in the non-existent
// suffix. The normalize_dotdot fix resolves this.
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("doesnt_exist/../../etc/passwd", "--output");
std::env::set_current_dir(&saved_cwd).unwrap();

assert!(
result.is_err(),
"traversal via non-existent prefix should be rejected"
);
}
}
Loading