From b03428d6afae6ef0de6a1a2e8dcfe6b0a7091fc7 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 5 Apr 2026 21:44:45 -0700 Subject: [PATCH 1/4] fix(security): validate resource IDs in docs, sheets, calendar, and drive helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds validate_resource_name() calls before embedding user-supplied IDs in API request params. Rejects path traversal, control characters, and URL injection via ?, #, and % characters — consistent with AGENTS.md requirements for AI-agent-safe input handling. Affected helpers: - docs +write: --document - sheets +append and +read: --spreadsheet - calendar +insert: --calendar - drive +upload: --parent Includes rejection tests for traversal and query injection in each helper. --- .../validate-resource-ids-in-helpers.md | 11 +++++++ .../src/helpers/calendar.rs | 23 ++++++++++++- .../google-workspace-cli/src/helpers/docs.rs | 19 ++++++++++- .../google-workspace-cli/src/helpers/drive.rs | 27 +++++++++++++--- .../src/helpers/sheets.rs | 32 +++++++++++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 .changeset/validate-resource-ids-in-helpers.md diff --git a/.changeset/validate-resource-ids-in-helpers.md b/.changeset/validate-resource-ids-in-helpers.md new file mode 100644 index 00000000..72dabaee --- /dev/null +++ b/.changeset/validate-resource-ids-in-helpers.md @@ -0,0 +1,11 @@ +--- +"@googleworkspace/cli": patch +--- + +Validate resource IDs in docs, sheets, calendar, and drive helpers + +`document_id` (docs `+write`), `spreadsheet_id` (sheets `+append` and `+read`), +`calendar_id` (calendar `+insert`), and `parent_id` (drive `+upload`) are now +validated with `validate_resource_name()` before use. This rejects path traversal +segments (`../`), control characters, and URL-special characters (`?`, `#`, `%`) +that could be injected by adversarial AI-agent inputs. diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index cf28b249..2df89bb1 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -423,7 +423,8 @@ fn build_insert_request( matches: &ArgMatches, doc: &crate::discovery::RestDescription, ) -> Result<(String, String, Vec), GwsError> { - let calendar_id = matches.get_one::("calendar").unwrap(); + let calendar_id = + crate::validate::validate_resource_name(matches.get_one::("calendar").unwrap())?; let summary = matches.get_one::("summary").unwrap(); let start = matches.get_one::("start").unwrap(); let end = matches.get_one::("end").unwrap(); @@ -576,6 +577,26 @@ mod tests { assert_eq!(scopes[0], "https://scope"); } + #[test] + fn test_build_insert_request_rejects_traversal_calendar_id() { + let doc = make_mock_doc(); + let matches = make_matches_insert(&[ + "test", + "--calendar", + "../../.ssh/id_rsa", + "--summary", + "X", + "--start", + "2024-01-01T10:00:00Z", + "--end", + "2024-01-01T11:00:00Z", + ]); + assert!( + build_insert_request(&matches, &doc).is_err(), + "path traversal in --calendar must be rejected" + ); + } + #[test] fn test_build_insert_request_with_meet() { let doc = make_mock_doc(); diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index d3ef7fa2..0cd654d0 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -120,7 +120,8 @@ fn build_write_request( matches: &ArgMatches, doc: &crate::discovery::RestDescription, ) -> Result<(String, String, Vec), GwsError> { - let document_id = matches.get_one::("document").unwrap(); + let document_id = + crate::validate::validate_resource_name(matches.get_one::("document").unwrap())?; let text = matches.get_one::("text").unwrap(); let documents_res = doc @@ -203,4 +204,20 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } + + #[test] + fn test_build_write_request_rejects_traversal_document_id() { + let doc = make_mock_doc(); + let matches = make_matches_write(&["test", "--document", "../../.ssh/id_rsa", "--text", "x"]); + let result = build_write_request(&matches, &doc); + assert!(result.is_err(), "path traversal in --document must be rejected"); + } + + #[test] + fn test_build_write_request_rejects_query_injection_document_id() { + let doc = make_mock_doc(); + let matches = make_matches_write(&["test", "--document", "abc?evil=1", "--text", "x"]); + let result = build_write_request(&matches, &doc); + assert!(result.is_err(), "'?' in --document must be rejected"); + } } diff --git a/crates/google-workspace-cli/src/helpers/drive.rs b/crates/google-workspace-cli/src/helpers/drive.rs index 68662ec6..3e2919b4 100644 --- a/crates/google-workspace-cli/src/helpers/drive.rs +++ b/crates/google-workspace-cli/src/helpers/drive.rs @@ -91,7 +91,7 @@ TIPS: })?; // Build metadata - let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str())); + let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str()))?; let body_str = metadata.to_string(); @@ -142,16 +142,17 @@ fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result) -> Value { +fn build_metadata(filename: &str, parent_id: Option<&str>) -> Result { let mut metadata = json!({ "name": filename }); if let Some(parent) = parent_id { + crate::validate::validate_resource_name(parent)?; metadata["parents"] = json!([parent]); } - metadata + Ok(metadata) } #[cfg(test)] @@ -182,15 +183,31 @@ mod tests { #[test] fn test_build_metadata_no_parent() { - let meta = build_metadata("file.txt", None); + let meta = build_metadata("file.txt", None).unwrap(); assert_eq!(meta["name"], "file.txt"); assert!(meta.get("parents").is_none()); } #[test] fn test_build_metadata_with_parent() { - let meta = build_metadata("file.txt", Some("folder123")); + let meta = build_metadata("file.txt", Some("folder123")).unwrap(); assert_eq!(meta["name"], "file.txt"); assert_eq!(meta["parents"][0], "folder123"); } + + #[test] + fn test_build_metadata_rejects_traversal_parent_id() { + assert!( + build_metadata("file.txt", Some("../../.ssh/id_rsa")).is_err(), + "path traversal in --parent must be rejected" + ); + } + + #[test] + fn test_build_metadata_rejects_query_injection_parent_id() { + assert!( + build_metadata("file.txt", Some("folder?evil=1")).is_err(), + "'?' in --parent must be rejected" + ); + } } diff --git a/crates/google-workspace-cli/src/helpers/sheets.rs b/crates/google-workspace-cli/src/helpers/sheets.rs index 4357edec..e8c2a3ed 100644 --- a/crates/google-workspace-cli/src/helpers/sheets.rs +++ b/crates/google-workspace-cli/src/helpers/sheets.rs @@ -209,6 +209,8 @@ fn build_append_request( config: &AppendConfig, doc: &crate::discovery::RestDescription, ) -> Result<(String, String, Vec), GwsError> { + crate::validate::validate_resource_name(&config.spreadsheet_id)?; + let spreadsheets_res = doc .resources .get("spreadsheets") @@ -240,6 +242,8 @@ fn build_read_request( config: &ReadConfig, doc: &crate::discovery::RestDescription, ) -> Result<(String, Vec), GwsError> { + crate::validate::validate_resource_name(&config.spreadsheet_id)?; + // ... resource lookup omitted for brevity ... let spreadsheets_res = doc .resources @@ -309,6 +313,7 @@ pub fn parse_append_args(matches: &ArgMatches) -> AppendConfig { } } + /// Configuration for reading values from a spreadsheet. pub struct ReadConfig { pub spreadsheet_id: String, @@ -522,4 +527,31 @@ mod tests { assert!(subcommands.contains(&"+append")); assert!(subcommands.contains(&"+read")); } + + #[test] + fn test_build_append_request_rejects_traversal() { + let doc = make_mock_doc(); + let config = AppendConfig { + spreadsheet_id: "../../.ssh/id_rsa".to_string(), + range: "A1".to_string(), + values: vec![vec!["x".to_string()]], + }; + assert!( + build_append_request(&config, &doc).is_err(), + "path traversal in spreadsheet ID must be rejected" + ); + } + + #[test] + fn test_build_read_request_rejects_query_injection() { + let doc = make_mock_doc(); + let config = ReadConfig { + spreadsheet_id: "abc?evil=1".to_string(), + range: "A1:B2".to_string(), + }; + assert!( + build_read_request(&config, &doc).is_err(), + "'?' in spreadsheet ID must be rejected" + ); + } } From c8a5fa1b4aee9e8e79c2069e8b68fa8f3dd6b40d Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 5 Apr 2026 21:44:45 -0700 Subject: [PATCH 2/4] fixup: use validated str from validate_resource_name; also validate range - drive: use the &str returned by validate_resource_name(parent)? directly in json! rather than the original untrusted input - sheets: validate config.range in both build_append_request and build_read_request, since the range is used as a URL path segment in the Sheets API (spreadsheets/{id}/values/{range}) --- crates/google-workspace-cli/src/helpers/drive.rs | 2 +- crates/google-workspace-cli/src/helpers/sheets.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/helpers/drive.rs b/crates/google-workspace-cli/src/helpers/drive.rs index 3e2919b4..e35d6874 100644 --- a/crates/google-workspace-cli/src/helpers/drive.rs +++ b/crates/google-workspace-cli/src/helpers/drive.rs @@ -148,7 +148,7 @@ fn build_metadata(filename: &str, parent_id: Option<&str>) -> Result Result<(String, String, Vec), GwsError> { crate::validate::validate_resource_name(&config.spreadsheet_id)?; + crate::validate::validate_resource_name(&config.range)?; let spreadsheets_res = doc .resources @@ -243,6 +244,7 @@ fn build_read_request( doc: &crate::discovery::RestDescription, ) -> Result<(String, Vec), GwsError> { crate::validate::validate_resource_name(&config.spreadsheet_id)?; + crate::validate::validate_resource_name(&config.range)?; // ... resource lookup omitted for brevity ... let spreadsheets_res = doc From d0e3da7e85e18e2f2b3b257290d13bfadc40299f Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 5 Apr 2026 22:10:13 -0700 Subject: [PATCH 3/4] fixup: address review feedback - remove calendar/range validation regressions; use validated spreadsheet_id - calendar_id can be an email address (user@example.com) so validate_resource_name would incorrectly reject valid inputs; revert to plain unwrap - range in Sheets A1 notation (e.g. Sheet1!A1) contains ! and : which are rejected by validate_resource_name; remove range validation from both build functions - capture and use the return value of validate_resource_name for spreadsheet_id so any normalization is applied to the actual request params --- crates/google-workspace-cli/src/helpers/calendar.rs | 3 +-- crates/google-workspace-cli/src/helpers/sheets.rs | 10 ++++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index 2df89bb1..55549038 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -423,8 +423,7 @@ fn build_insert_request( matches: &ArgMatches, doc: &crate::discovery::RestDescription, ) -> Result<(String, String, Vec), GwsError> { - let calendar_id = - crate::validate::validate_resource_name(matches.get_one::("calendar").unwrap())?; + let calendar_id = matches.get_one::("calendar").unwrap(); let summary = matches.get_one::("summary").unwrap(); let start = matches.get_one::("start").unwrap(); let end = matches.get_one::("end").unwrap(); diff --git a/crates/google-workspace-cli/src/helpers/sheets.rs b/crates/google-workspace-cli/src/helpers/sheets.rs index 3aff5633..6893a66c 100644 --- a/crates/google-workspace-cli/src/helpers/sheets.rs +++ b/crates/google-workspace-cli/src/helpers/sheets.rs @@ -209,8 +209,7 @@ fn build_append_request( config: &AppendConfig, doc: &crate::discovery::RestDescription, ) -> Result<(String, String, Vec), GwsError> { - crate::validate::validate_resource_name(&config.spreadsheet_id)?; - crate::validate::validate_resource_name(&config.range)?; + let spreadsheet_id = crate::validate::validate_resource_name(&config.spreadsheet_id)?; let spreadsheets_res = doc .resources @@ -224,7 +223,7 @@ fn build_append_request( })?; let params = json!({ - "spreadsheetId": config.spreadsheet_id, + "spreadsheetId": spreadsheet_id, "range": config.range, "valueInputOption": "USER_ENTERED" }); @@ -243,8 +242,7 @@ fn build_read_request( config: &ReadConfig, doc: &crate::discovery::RestDescription, ) -> Result<(String, Vec), GwsError> { - crate::validate::validate_resource_name(&config.spreadsheet_id)?; - crate::validate::validate_resource_name(&config.range)?; + let spreadsheet_id = crate::validate::validate_resource_name(&config.spreadsheet_id)?; // ... resource lookup omitted for brevity ... let spreadsheets_res = doc @@ -259,7 +257,7 @@ fn build_read_request( })?; let params = json!({ - "spreadsheetId": config.spreadsheet_id, + "spreadsheetId": spreadsheet_id, "range": config.range }); From 86b5efaf00a2b0b08f95312d5c2c8301caaa8ac2 Mon Sep 17 00:00:00 2001 From: Varun Nuthalapati Date: Sun, 5 Apr 2026 22:19:33 -0700 Subject: [PATCH 4/4] fixup: restore validate_resource_name for calendar_id in build_insert_request validate_resource_name only rejects control chars, path traversal, ?, #, and %. It does NOT reject @ or . so email-style calendar IDs (user@example.com) pass validation correctly. The earlier comment to remove it was based on a mistaken assumption about the validator's character set. --- crates/google-workspace-cli/src/helpers/calendar.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index 55549038..2df89bb1 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -423,7 +423,8 @@ fn build_insert_request( matches: &ArgMatches, doc: &crate::discovery::RestDescription, ) -> Result<(String, String, Vec), GwsError> { - let calendar_id = matches.get_one::("calendar").unwrap(); + let calendar_id = + crate::validate::validate_resource_name(matches.get_one::("calendar").unwrap())?; let summary = matches.get_one::("summary").unwrap(); let start = matches.get_one::("start").unwrap(); let end = matches.get_one::("end").unwrap();