fix: harden URL encoding and input validation for AI/LLM callers (#21)

* refactor: replace manual urlencoded() with reqwest .query() builder

Remove duplicate hand-rolled urlencoded() functions from workflows.rs
and calendar.rs. All query parameters are now passed via reqwest's
.query() API, which handles percent-encoding correctly and completely.

* fix: percent-encode path parameters to prevent path traversal

Use percent_encoding::utf8_percent_encode for calendar_id, cal.id,
message_id, and file_id before interpolating into URL path segments.
Addresses code review feedback on security regression.

* fix: add shared URL safety helpers for path params

Add encode_path_segment() for single-segment IDs and
validate_resource_name() for multi-segment resource names.

encode_path_segment: percent-encodes all non-alphanumeric chars,
used for calendar IDs, file IDs, and message IDs.

validate_resource_name: rejects path traversal (..) and control
chars while preserving intentional / structure, used for Chat
space names, task list IDs, and subscription names. Returns clear
error messages for LLM callers.

* test: add AI edge case tests for URL safety helpers

Cover query/fragment injection, double-encoding, unicode, spaces,
path traversal via encoding, control chars (CR/tab), and clear
error message assertions for LLM callers.

* fix: warn on stderr when API calls fail silently

- Daily briefing calendar events fetch
- Daily briefing tasks fetch
- Daily summary calendar events fetch
- Daily summary unread email count fetch

Addresses PR review feedback about confusing silent failures,
especially for LLM callers that cannot see visual cues.

* fix: harden input validation for AI/LLM callers

- Add src/validate.rs with validate_safe_output_dir, validate_msg_format,
  and validate_safe_dir_path helpers
- Validate --output-dir against path traversal in gmail +watch and
  events +subscribe
- Validate --msg-format against allowlist in gmail +watch
- Validate --dir against path traversal in script +push
- Add clap value_parser constraint for --msg-format
- Document input validation patterns in AGENTS.md

Closes #23

* chore: add changesets for PR #21 commits

* test: add comprehensive test coverage for input validation handlers

* docs: document input validation and URL safety patterns in AGENTS.md and CONTRIBUTING.md

* fix: address PR review comments — reject ?/# in resource names, validate subscription arg, remove redundant validate_msg_format

* fix: store validated PathBuf, remove dead code, delete duplicate SubscribeConfig

Addresses review comments:
- Store validated PathBuf from validate_safe_output_dir instead of
  discarding it (output_dir is now Option<PathBuf>)
- Remove duplicate SubscribeConfig from events/mod.rs
- Delete unused validate_msg_format (clap value_parser handles this)
- Remove all #[allow(dead_code)] annotations

* fix: per-segment traversal check in validate_resource_name, fix docs

* fix: harden security validation and deduplicate logic

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-03 18:36:41 -07:00
committed by GitHub
parent 76643573b3
commit 90adcb4379
21 changed files with 908 additions and 116 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix: percent-encode path parameters to prevent path traversal
+12
View File
@@ -0,0 +1,12 @@
---
"@googleworkspace/cli": patch
---
fix: harden input validation for AI/LLM callers
- Add `src/validate.rs` with `validate_safe_output_dir`, `validate_msg_format`, and `validate_safe_dir_path` helpers
- Validate `--output-dir` against path traversal in `gmail +watch` and `events +subscribe`
- Validate `--msg-format` against allowlist (full, metadata, minimal, raw) in `gmail +watch`
- Validate `--dir` against path traversal in `script +push`
- Add clap `value_parser` constraint for `--msg-format`
- Document input validation patterns in `AGENTS.md`
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
Security: Harden validate_resource_name and fix Gmail watch path traversal
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
Replace manual `urlencoded()` with reqwest `.query()` builder for safer URL encoding
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix: add shared URL safety helpers for path params (`encode_path_segment`, `validate_resource_name`)
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix: warn on stderr when API calls fail silently
+71
View File
@@ -72,6 +72,77 @@ vhs docs/demo.tape
ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears the screen and cats the file. Portrait scenes use `scene*.txt`; landscape chapters use `long-*.txt`.
## Input Validation & URL Safety
> [!IMPORTANT]
> This CLI is frequently invoked by AI/LLM agents. Always assume inputs can be adversarial — validate paths against traversal (`../../.ssh`), restrict format strings to allowlists, reject control characters, and encode user values before embedding them in URLs.
### Path Safety (`src/validate.rs`)
When adding new helpers or CLI flags that accept file paths, **always validate** using the shared helpers:
| Scenario | Validator | Rejects |
|---|---|---|
| File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars |
| Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist |
```rust
// In your argument parser:
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
crate::validate::validate_safe_output_dir(output_dir)?;
builder.output_dir(Some(output_dir.clone()));
}
```
### URL Encoding (`src/helpers/mod.rs`)
User-supplied values embedded in URL **path segments** must be percent-encoded. Use the shared helper:
```rust
// CORRECT — encodes slashes, spaces, and special characters
let url = format!(
"https://www.googleapis.com/drive/v3/files/{}",
crate::helpers::encode_path_segment(file_id),
);
// WRONG — raw user input in URL path
let url = format!("https://www.googleapis.com/drive/v3/files/{}", file_id);
```
For **query parameters**, use reqwest's `.query()` builder which handles encoding automatically:
```rust
// CORRECT — reqwest encodes query values
client.get(url).query(&[("q", user_query)]).send().await?;
// WRONG — manual string interpolation in query strings
let url = format!("{}?q={}", base_url, user_query);
```
### Resource Name Validation (`src/helpers/mod.rs`)
When a user-supplied string is used as a GCP resource identifier (project ID, topic name, space name, etc.) that gets embedded in a URL path, validate it first:
```rust
// Validates the string does not contain path traversal segments (`..`), control characters, or URL-breaking characters like `?` and `#`.
let project = crate::helpers::validate_resource_name(&project_id)?;
let url = format!("https://pubsub.googleapis.com/v1/projects/{}/topics/my-topic", project);
```
This prevents injection of query parameters, path traversal, or other malicious payloads through resource name arguments like `--project` or `--space`.
### Checklist for New Features
When adding a new helper or CLI command:
1. **File paths** → Use `validate_safe_output_dir` / `validate_safe_dir_path`
2. **Enum flags** → Constrain via clap `value_parser` or `validate_msg_format`
3. **URL path segments** → Use `encode_path_segment()`
4. **Query parameters** → Use reqwest `.query()` builder
5. **Resource names** (project IDs, space names, topic names) → Use `validate_resource_name()`
6. **Write tests** for both the happy path AND the rejection path (e.g., pass `../../.ssh` and assert `Err`)
## Environment Variables
- `GOOGLE_WORKSPACE_CLI_TOKEN` — Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading)
Generated
+1
View File
@@ -860,6 +860,7 @@ dependencies = [
"futures-util",
"hostname",
"keyring",
"percent-encoding",
"rand 0.8.5",
"ratatui",
"reqwest",
+1
View File
@@ -55,6 +55,7 @@ chrono = "0.4.44"
keyring = "3.6.3"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
percent-encoding = "2.3.2"
# The profile that 'cargo dist' will build with
+36 -1
View File
@@ -55,4 +55,39 @@ If the OAuth refresh token used in the GitHub Actions smoketest expires or needs
```bash
rm smoketest-creds.json
unset GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE
```
```
## Development Patterns
### Changesets
Every PR must include a changeset file at `.changeset/<descriptive-name>.md`:
```markdown
---
"@googleworkspace/cli": patch
---
Brief description of the change
```
Use `patch` for fixes/chores, `minor` for new features, `major` for breaking changes.
### Input Validation & URL Safety
This CLI is designed to be invoked by AI/LLM agents, so all user-supplied inputs must be treated as potentially adversarial. See [AGENTS.md](../AGENTS.md#input-validation--url-safety) for the full reference. The key rules are:
| What you're doing | What to use |
|---|---|
| Accepting a file path (`--output-dir`, `--dir`) | `validate::validate_safe_output_dir()` or `validate_safe_dir_path()` |
| Embedding a value in a URL path segment | `helpers::encode_path_segment()` |
| Passing query parameters | reqwest `.query()` builder (never string interpolation) |
| Using a resource name in a URL (`--project`, `--space`) | `helpers::validate_resource_name()` |
| Accepting an enum flag (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) |
### Testing Expectations
- All new validation logic must include **both happy-path and error-path tests**
- Tests that modify the process CWD must use `#[serial]` from `serial_test`
- Tempdir paths should be canonicalized before use to handle macOS `/var` → `/private/var` symlinks
- Run the full suite before submitting: `cargo test && cargo clippy -- -D warnings`
+3 -1
View File
@@ -68,7 +68,9 @@ struct SkillIndexEntry {
/// Entry point for `gws generate-skills`.
pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> {
let output_dir = parse_output_dir(args);
let output_path = Path::new(&output_dir);
// Validate output_dir to prevent path traversal
let output_path_buf = crate::validate::validate_safe_output_dir(&output_dir)?;
let output_path = output_path_buf.as_path();
let filter = parse_filter(args);
let mut index: Vec<SkillIndexEntry> = Vec::new();
+12 -13
View File
@@ -301,14 +301,21 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
let time_max = &time_max;
async move {
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events?timeMin={}&timeMax={}&singleEvents=true&orderBy=startTime&maxResults=50",
urlencoded(&cal.id),
urlencoded(time_min),
urlencoded(time_max),
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
crate::validate::encode_path_segment(&cal.id),
);
let resp = crate::client::send_with_retry(|| {
client.get(&events_url).bearer_auth(token)
client
.get(&events_url)
.query(&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "50"),
])
.bearer_auth(token)
})
.await;
@@ -391,14 +398,6 @@ fn epoch_to_rfc3339(epoch: u64) -> String {
Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339()
}
fn urlencoded(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('@', "%40")
.replace('+', "%2B")
.replace(':', "%3A")
}
fn build_insert_request(
matches: &ArgMatches,
doc: &crate::discovery::RestDescription,
-25
View File
@@ -49,31 +49,6 @@ impl std::fmt::Display for SubscriptionName {
}
}
#[derive(Debug, Clone, Builder)]
#[builder(setter(into))]
pub struct SubscribeConfig {
#[builder(default)]
pub target: Option<String>,
#[builder(default)]
pub event_types: Vec<String>,
#[builder(default)]
pub project: Option<ProjectId>,
#[builder(default)]
pub subscription: Option<SubscriptionName>,
#[builder(default = "10")]
pub max_messages: u32,
#[builder(default = "5")]
pub poll_interval: u64,
#[builder(default = "false")]
pub once: bool,
#[builder(default = "false")]
pub cleanup: bool,
#[builder(default = "false")]
pub no_ack: bool,
#[builder(default)]
pub output_dir: Option<String>,
}
impl Helper for EventsHelper {
fn inject_commands(
&self,
+2
View File
@@ -37,6 +37,7 @@ pub(super) async fn handle_renew(
if let Some(name) = config.name {
// Reactivate a specific subscription
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Reactivating subscription: {name}");
let resp = client
.post(format!(
@@ -78,6 +79,7 @@ pub(super) async fn handle_renew(
let to_renew = filter_subscriptions_to_renew(subs, now, within_secs);
for name in to_renew {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Renewing {name}...");
let _ = client
.post(format!(
+43 -5
View File
@@ -1,4 +1,30 @@
use super::*;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Builder)]
#[builder(setter(into))]
pub struct SubscribeConfig {
#[builder(default)]
target: Option<String>,
#[builder(default)]
event_types: Vec<String>,
#[builder(default)]
project: Option<ProjectId>,
#[builder(default)]
subscription: Option<SubscriptionName>,
#[builder(default = "10")]
max_messages: u32,
#[builder(default = "2")]
poll_interval: u64,
#[builder(default)]
once: bool,
#[builder(default)]
cleanup: bool,
#[builder(default)]
no_ack: bool,
#[builder(default)]
output_dir: Option<PathBuf>,
}
fn parse_subscribe_args(matches: &ArgMatches) -> Result<SubscribeConfig, GwsError> {
let mut builder = SubscribeConfigBuilder::default();
@@ -22,6 +48,7 @@ fn parse_subscribe_args(matches: &ArgMatches) -> Result<SubscribeConfig, GwsErro
builder.project(Some(ProjectId(project)));
}
if let Some(subscription) = matches.get_one::<String>("subscription") {
crate::validate::validate_resource_name(subscription)?;
builder.subscription(Some(SubscriptionName(subscription.clone())));
}
if let Some(max_messages) = matches
@@ -40,7 +67,7 @@ fn parse_subscribe_args(matches: &ArgMatches) -> Result<SubscribeConfig, GwsErro
builder.cleanup(matches.get_flag("cleanup"));
builder.no_ack(matches.get_flag("no-ack"));
if let Some(output_dir) = matches.get_one::<String>("output-dir") {
builder.output_dir(Some(output_dir.clone()));
builder.output_dir(Some(crate::validate::validate_safe_output_dir(output_dir)?));
}
let config = builder
@@ -96,7 +123,9 @@ pub(super) async fn handle_subscribe(
} else {
// Full setup: create Pub/Sub topic + subscription + Workspace Events subscription
let target = config.target.clone().unwrap();
let project = config.project.clone().unwrap().0;
let project =
crate::validate::validate_resource_name(&config.project.clone().unwrap().0)?
.to_string();
let event_types_str: Vec<&str> =
config.event_types.iter().map(|s| s.as_str()).collect();
@@ -326,11 +355,11 @@ async fn pull_loop(
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let path = format!("{dir}/{ts}_{file_counter}.json");
let path = dir.join(format!("{ts}_{file_counter}.json"));
if let Err(e) = std::fs::write(&path, &json_str) {
eprintln!("Warning: failed to write {path}: {e}");
eprintln!("Warning: failed to write {}: {e}", path.display());
} else {
eprintln!("Wrote {path}");
eprintln!("Wrote {}", path.display());
}
} else {
println!(
@@ -514,6 +543,15 @@ mod tests {
cmd.try_get_matches_from(args).unwrap()
}
#[test]
fn test_parse_subscribe_args_invalid_output_dir() {
let matches = make_matches_subscribe(&["test", "--output-dir", "../../etc"]);
let result = parse_subscribe_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_subscribe_args() {
let matches = make_matches_subscribe(&[
+1
View File
@@ -161,6 +161,7 @@ TIPS:
.long("msg-format")
.help("Gmail message format: full, metadata, minimal, raw")
.value_name("FORMAT")
.value_parser(["full", "metadata", "minimal", "raw"])
.default_value("full"),
)
.arg(
+66 -26
View File
@@ -5,7 +5,7 @@ pub(super) async fn handle_watch(
matches: &ArgMatches,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
) -> Result<(), GwsError> {
let config = parse_watch_args(matches);
let config = parse_watch_args(matches)?;
if let Some(ref dir) = config.output_dir {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
@@ -37,8 +37,9 @@ pub(super) async fn handle_watch(
let suffix = format!("{:08x}", rand::random::<u32>());
let topic = if let Some(ref t) = config.topic {
t.clone()
crate::validate::validate_resource_name(t)?.to_string()
} else {
let project = crate::validate::validate_resource_name(&project)?;
let t = format!("projects/{project}/topics/gws-gmail-watch-{suffix}");
// Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {t}");
@@ -97,6 +98,7 @@ pub(super) async fn handle_watch(
t
};
let project = crate::validate::validate_resource_name(&project)?;
let sub = format!("projects/{project}/subscriptions/gws-gmail-watch-{suffix}");
// 3. Create Pub/Sub subscription
@@ -207,14 +209,15 @@ pub(super) async fn handle_watch(
eprintln!("\nCleaning up Pub/Sub resources...");
let _ = client
.delete(format!(
"https://pubsub.googleapis.com/v1/{pubsub_subscription}"
"https://pubsub.googleapis.com/v1/{}",
pubsub_subscription
))
.bearer_auth(&pubsub_token)
.send()
.await;
if let Some(ref topic) = topic_name {
let _ = client
.delete(format!("https://pubsub.googleapis.com/v1/{topic}"))
.delete(format!("https://pubsub.googleapis.com/v1/{}", topic))
.bearer_auth(&pubsub_token)
.send()
.await;
@@ -227,9 +230,9 @@ pub(super) async fn handle_watch(
pubsub_subscription
);
if let Some(ref topic) = topic_name {
eprintln!("Pub/Sub topic: {topic}");
eprintln!("Pub/Sub topic: {}", topic);
}
eprintln!("Pub/Sub subscription: {pubsub_subscription}");
eprintln!("Pub/Sub subscription: {}", pubsub_subscription);
eprintln!("Note: Gmail watch expires after 7 days. Re-run +watch to renew.");
}
}
@@ -293,7 +296,7 @@ async fn watch_pull_loop(
gmail_token,
*last_history_id,
&config.format,
config.output_dir.as_deref(),
config.output_dir.as_ref(),
sanitize_config,
)
.await?;
@@ -375,7 +378,7 @@ async fn fetch_and_output_messages(
gmail_token: &str,
start_history_id: u64,
msg_format: &str,
output_dir: Option<&str>,
output_dir: Option<&std::path::PathBuf>,
sanitize_config: &crate::helpers::modelarmor::SanitizeConfig,
) -> Result<(), GwsError> {
let url = format!(
@@ -430,11 +433,14 @@ async fn fetch_and_output_messages(
let json_str =
serde_json::to_string_pretty(&full_msg).unwrap_or_else(|_| "{}".to_string());
if let Some(dir) = output_dir {
let path = format!("{dir}/{msg_id}.json");
let path = dir.join(format!(
"{}.json",
crate::validate::encode_path_segment(&msg_id)
));
if let Err(e) = std::fs::write(&path, &json_str) {
eprintln!("Warning: failed to write {path}: {e}");
eprintln!("Warning: failed to write {}: {e}", path.display());
} else {
eprintln!("Wrote {path}");
eprintln!("Wrote {}", path.display());
}
} else {
println!(
@@ -499,7 +505,7 @@ fn extract_message_ids_from_history(history_body: &Value) -> Vec<String> {
result
}
#[derive(Clone)]
#[derive(Debug, Clone)]
struct WatchConfig {
project: Option<String>,
subscription: Option<String>,
@@ -510,11 +516,22 @@ struct WatchConfig {
format: String,
once: bool,
cleanup: bool,
output_dir: Option<String>,
output_dir: Option<std::path::PathBuf>,
}
fn parse_watch_args(matches: &ArgMatches) -> WatchConfig {
WatchConfig {
fn parse_watch_args(matches: &ArgMatches) -> Result<WatchConfig, GwsError> {
let format_str = matches
.get_one::<String>("msg-format")
.map(|s| s.as_str())
.unwrap_or("full");
// Note: msg-format is already constrained by clap's value_parser
let output_dir = matches
.get_one::<String>("output-dir")
.map(|dir| crate::validate::validate_safe_output_dir(dir))
.transpose()?;
Ok(WatchConfig {
project: matches.get_one::<String>("project").cloned(),
subscription: matches.get_one::<String>("subscription").cloned(),
topic: matches.get_one::<String>("topic").cloned(),
@@ -527,15 +544,11 @@ fn parse_watch_args(matches: &ArgMatches) -> WatchConfig {
.get_one::<String>("poll-interval")
.and_then(|s| s.parse().ok())
.unwrap_or(5),
format: matches
.get_one::<String>("msg-format")
.map(|s| s.as_str())
.unwrap_or("full")
.to_string(),
format: format_str.to_string(),
once: matches.get_flag("once"),
cleanup: matches.get_flag("cleanup"),
output_dir: matches.get_one::<String>("output-dir").cloned(),
}
output_dir,
})
}
#[cfg(test)]
@@ -623,7 +636,34 @@ mod tests {
}
#[test]
fn test_parse_watch_args() {
fn test_parse_watch_args_invalid_format_rejected_by_clap() {
// msg-format is constrained by clap's value_parser, so invalid values
// are rejected at the clap level before parse_watch_args is called.
// Verify the real command definition rejects bad formats:
let helper = super::super::GmailHelper;
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(Command::new("test"), &doc);
let watch_cmd = cmd
.get_subcommands()
.find(|c| c.get_name() == "+watch")
.unwrap()
.clone();
let result =
watch_cmd.try_get_matches_from(vec!["+watch", "--msg-format", "invalid-format"]);
assert!(result.is_err());
}
#[test]
fn test_parse_watch_args_invalid_output_dir() {
let matches = make_matches_watch(&["test", "--output-dir", "../../etc"]);
let result = parse_watch_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_watch_args_full() {
let matches = make_matches_watch(&[
"test",
"--project",
@@ -634,7 +674,7 @@ mod tests {
"20",
"--once",
]);
let config = parse_watch_args(&matches);
let config = parse_watch_args(&matches).unwrap();
assert_eq!(config.project.unwrap(), "p1");
assert_eq!(config.subscription.unwrap(), "s1");
assert_eq!(config.max_messages, 20);
@@ -651,7 +691,7 @@ mod tests {
#[test]
fn test_parse_watch_args_defaults() {
let matches = make_matches_watch(&["test"]);
let config = parse_watch_args(&matches);
let config = parse_watch_args(&matches).unwrap();
assert_eq!(config.project, None);
assert_eq!(config.subscription, None);
assert_eq!(config.max_messages, 10);
@@ -670,7 +710,7 @@ mod tests {
"--poll-interval",
"invalid",
]);
let config = parse_watch_args(&matches);
let config = parse_watch_args(&matches).unwrap();
// Should fallback to defaults
assert_eq!(config.max_messages, 10);
assert_eq!(config.poll_interval, 5);
+2 -1
View File
@@ -76,9 +76,10 @@ TIPS:
.get_one::<String>("dir")
.map(|s| s.as_str())
.unwrap_or(".");
let safe_dir = crate::validate::validate_safe_dir_path(dir_path)?;
let mut files = Vec::new();
visit_dirs(Path::new(dir_path), &mut files)?;
visit_dirs(&safe_dir, &mut files)?;
if files.is_empty() {
return Err(GwsError::Validation(format!(
+158 -44
View File
@@ -228,9 +228,15 @@ TIPS:
// Handlers
// ---------------------------------------------------------------------------
async fn get_json(client: &reqwest::Client, url: &str, token: &str) -> Result<Value, GwsError> {
async fn get_json(
client: &reqwest::Client,
url: &str,
token: &str,
query: &[(&str, &str)],
) -> Result<Value, GwsError> {
let resp = client
.get(url)
.query(query)
.bearer_auth(token)
.send()
.await
@@ -279,14 +285,24 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
let time_max = epoch_to_rfc3339(day_end);
// Fetch today's events
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin={}&timeMax={}&singleEvents=true&orderBy=startTime&maxResults=25",
urlencoded(&time_min),
urlencoded(&time_max),
);
let events_json = get_json(&client, &events_url, &token)
.await
.unwrap_or(json!({}));
let events_json = get_json(
&client,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
&token,
&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "25"),
],
)
.await
.map_err(|e| {
eprintln!("Warning: Failed to fetch calendar events: {e}");
e
})
.unwrap_or(json!({}));
let events = events_json
.get("items")
.and_then(|i| i.as_array())
@@ -305,10 +321,18 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
.collect();
// Fetch open tasks
let tasks_url = "https://tasks.googleapis.com/tasks/v1/lists/@default/tasks?showCompleted=false&maxResults=20";
let tasks_json = get_json(&client, tasks_url, &token)
.await
.unwrap_or(json!({}));
let tasks_json = get_json(
&client,
"https://tasks.googleapis.com/tasks/v1/lists/@default/tasks",
&token,
&[("showCompleted", "false"), ("maxResults", "20")],
)
.await
.map_err(|e| {
eprintln!("Warning: Failed to fetch tasks: {e}");
e
})
.unwrap_or(json!({}));
let tasks = tasks_json
.get("items")
.and_then(|i| i.as_array())
@@ -358,11 +382,21 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
);
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/{}/events?timeMin={}&singleEvents=true&orderBy=startTime&maxResults=1",
urlencoded(calendar_id),
urlencoded(&now_rfc),
"https://www.googleapis.com/calendar/v3/calendars/{}/events",
crate::validate::encode_path_segment(calendar_id),
);
let events_json = get_json(&client, &events_url, &token).await?;
let events_json = get_json(
&client,
&events_url,
&token,
&[
("timeMin", now_rfc.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "1"),
],
)
.await?;
let items = events_json
.get("items")
.and_then(|i| i.as_array())
@@ -424,10 +458,16 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
// 1. Fetch the email
let msg_url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}?format=metadata&metadataHeaders=Subject",
message_id,
"https://gmail.googleapis.com/gmail/v1/users/me/messages/{}",
crate::validate::encode_path_segment(message_id),
);
let msg_json = get_json(&client, &msg_url, &token).await?;
let msg_json = get_json(
&client,
&msg_url,
&token,
&[("format", "metadata"), ("metadataHeaders", "Subject")],
)
.await?;
let subject = msg_json
.get("payload")
@@ -455,6 +495,7 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
"notes": format!("From email: {}\n\n{}", message_id, snippet),
});
let tasklist = crate::validate::validate_resource_name(tasklist)?;
let task_url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks",
tasklist,
@@ -508,14 +549,24 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
let time_max = epoch_to_rfc3339(week_end);
// Fetch this week's events
let events_url = format!(
"https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin={}&timeMax={}&singleEvents=true&orderBy=startTime&maxResults=50",
urlencoded(&time_min),
urlencoded(&time_max),
);
let events_json = get_json(&client, &events_url, &token)
.await
.unwrap_or(json!({}));
let events_json = get_json(
&client,
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
&token,
&[
("timeMin", time_min.as_str()),
("timeMax", time_max.as_str()),
("singleEvents", "true"),
("orderBy", "startTime"),
("maxResults", "50"),
],
)
.await
.map_err(|e| {
eprintln!("Warning: Failed to fetch calendar events: {e}");
e
})
.unwrap_or(json!({}));
let events = events_json
.get("items")
.and_then(|i| i.as_array())
@@ -533,11 +584,18 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
.collect();
// Fetch unread email count
let gmail_url =
"https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is%3Aunread&maxResults=1";
let gmail_json = get_json(&client, gmail_url, &token)
.await
.unwrap_or(json!({}));
let gmail_json = get_json(
&client,
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
&token,
&[("q", "is:unread"), ("maxResults", "1")],
)
.await
.map_err(|e| {
eprintln!("Warning: Failed to fetch unread email count: {e}");
e
})
.unwrap_or(json!({}));
let unread_estimate = gmail_json
.get("resultSizeEstimate")
.and_then(|v| v.as_u64())
@@ -569,10 +627,16 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
// 1. Fetch file metadata from Drive
let file_url = format!(
"https://www.googleapis.com/drive/v3/files/{}?fields=id,name,webViewLink",
file_id,
"https://www.googleapis.com/drive/v3/files/{}",
crate::validate::encode_path_segment(file_id),
);
let file_json = get_json(&client, &file_url, &token).await?;
let file_json = get_json(
&client,
&file_url,
&token,
&[("fields", "id,name,webViewLink")],
)
.await?;
let file_name = file_json
.get("name")
.and_then(|v| v.as_str())
@@ -589,6 +653,7 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
.unwrap_or_else(|| format!("📎 {file_name}\n{file_link}"));
let chat_body = json!({ "text": msg_text });
let space = crate::validate::validate_resource_name(space)?;
let chat_url = format!("https://chat.googleapis.com/v1/{}/messages", space);
let chat_resp = client
@@ -629,14 +694,6 @@ fn epoch_to_rfc3339(epoch: u64) -> String {
Utc.timestamp_opt(epoch as i64, 0).unwrap().to_rfc3339()
}
fn urlencoded(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('@', "%40")
.replace('+', "%2B")
.replace(':', "%3A")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -662,4 +719,61 @@ mod tests {
fn test_helper_only() {
assert!(WorkflowHelper.helper_only());
}
#[test]
fn test_epoch_to_rfc3339() {
assert_eq!(epoch_to_rfc3339(0), "1970-01-01T00:00:00+00:00");
assert_eq!(epoch_to_rfc3339(1710000000), "2024-03-09T16:00:00+00:00");
}
#[test]
fn test_build_standup_report_cmd() {
let cmd = build_standup_report_cmd();
assert_eq!(cmd.get_name(), "+standup-report");
}
#[test]
fn test_build_meeting_prep_cmd() {
let cmd = build_meeting_prep_cmd();
assert_eq!(cmd.get_name(), "+meeting-prep");
}
#[test]
fn test_build_email_to_task_cmd() {
let cmd = build_email_to_task_cmd();
assert_eq!(cmd.get_name(), "+email-to-task");
// message-id is required
let args = cmd
.clone()
.try_get_matches_from(vec!["+email-to-task", "--message-id", "123"]);
assert!(args.is_ok());
let args_err = cmd.try_get_matches_from(vec!["+email-to-task"]);
assert!(args_err.is_err());
}
#[test]
fn test_build_weekly_digest_cmd() {
let cmd = build_weekly_digest_cmd();
assert_eq!(cmd.get_name(), "+weekly-digest");
}
#[test]
fn test_build_file_announce_cmd() {
let cmd = build_file_announce_cmd();
assert_eq!(cmd.get_name(), "+file-announce");
let args = cmd.clone().try_get_matches_from(vec![
"+file-announce",
"--file-id",
"123",
"--space",
"spaces/test",
]);
assert!(args.is_ok());
let args_err = cmd.try_get_matches_from(vec!["+file-announce"]);
assert!(args_err.is_err());
}
}
+1
View File
@@ -36,6 +36,7 @@ mod services;
mod setup;
mod setup_tui;
mod token_storage;
pub(crate) mod validate;
use error::{print_error_json, GwsError};
+474
View File
@@ -0,0 +1,474 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Shared input validation helpers.
//!
//! These functions harden CLI inputs against adversarial or accidentally
//! malformed values — especially important when the CLI is invoked by an
//! LLM agent rather than a human operator.
use crate::error::GwsError;
use std::path::{Path, PathBuf};
/// Validates that `dir` is a safe output directory.
///
/// The path is resolved relative to CWD. The function rejects paths that
/// would escape above CWD (e.g. `../../.ssh`) or contain null bytes /
/// control characters.
///
/// Returns the canonicalized path on success.
pub fn validate_safe_output_dir(dir: &str) -> Result<PathBuf, GwsError> {
reject_control_chars(dir, "--output-dir")?;
let path = Path::new(dir);
// Reject absolute paths — force everything relative to CWD
if path.is_absolute() {
return Err(GwsError::Validation(format!(
"--output-dir must be a relative path, got absolute path '{}'",
dir
)));
}
// Canonicalize CWD and resolve the target under it
let cwd = std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;
let resolved = cwd.join(path);
// If the directory already exists, canonicalize. Otherwise, canonicalize
// the longest existing prefix and append the remaining segments.
let canonical = if resolved.exists() {
resolved.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to resolve --output-dir '{}': {e}", dir))
})?
} 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!(
"--output-dir '{}' resolves to '{}' which is outside the current directory",
dir,
canonical.display()
)));
}
Ok(canonical)
}
/// Validates that `dir` is a safe directory for reading files (e.g. `--dir`
/// in `script +push`).
///
/// Similar to [`validate_safe_output_dir`] but also follows symlinks
/// safely and ensures the resolved path stays under CWD.
pub fn validate_safe_dir_path(dir: &str) -> Result<PathBuf, GwsError> {
reject_control_chars(dir, "--dir")?;
let path = Path::new(dir);
// "." is always safe (CWD itself)
if dir == "." {
return std::env::current_dir().map_err(|e| {
GwsError::Validation(format!("Failed to determine current directory: {e}"))
});
}
if path.is_absolute() {
return Err(GwsError::Validation(format!(
"--dir must be a relative path, got absolute path '{}'",
dir
)));
}
let cwd = std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;
let resolved = cwd.join(path);
let canonical = resolved
.canonicalize()
.map_err(|e| GwsError::Validation(format!("Failed to resolve --dir '{}': {e}", dir)))?;
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!(
"--dir '{}' resolves to '{}' which is outside the current directory",
dir,
canonical.display()
)));
}
Ok(canonical)
}
/// Rejects strings containing null bytes or ASCII control characters.
fn reject_control_chars(value: &str, flag_name: &str) -> Result<(), GwsError> {
if value.bytes().any(|b| b < 0x20) {
return Err(GwsError::Validation(format!(
"{flag_name} contains invalid control characters"
)));
}
Ok(())
}
/// Resolves a path that may not exist yet by canonicalizing the existing
/// prefix and appending remaining components.
fn normalize_non_existing(path: &Path) -> Result<PathBuf, GwsError> {
let mut resolved = PathBuf::new();
let mut remaining = Vec::new();
// Walk backwards until we find a component that exists
let mut current = path.to_path_buf();
loop {
if current.exists() {
resolved = current
.canonicalize()
.map_err(|e| GwsError::Validation(format!("Failed to canonicalize path: {e}")))?;
break;
}
if let Some(name) = current.file_name() {
remaining.push(name.to_os_string());
} else {
// We've exhausted the path without finding an existing prefix
return Err(GwsError::Validation(format!(
"Cannot resolve path '{}'",
path.display()
)));
}
current = match current.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
}
// Append remaining segments (in reverse since we collected them backwards)
for seg in remaining.into_iter().rev() {
resolved.push(seg);
}
Ok(resolved)
}
/// Percent-encode a value for use as a single URL path segment (e.g., file ID,
/// calendar ID, message ID). All non-alphanumeric characters are encoded.
pub fn encode_path_segment(s: &str) -> String {
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
}
/// Validate a multi-segment resource name (e.g., `spaces/ABC`, `subscriptions/123`).
/// Rejects path traversal, control characters, and URL-special characters including `%`
/// to prevent URL-encoded bypasses. Returns the validated name or an error.
pub fn validate_resource_name(s: &str) -> Result<&str, GwsError> {
if s.is_empty() {
return Err(GwsError::Validation(
"Resource name must not be empty".to_string(),
));
}
if s.split('/').any(|seg| seg == "..") {
return Err(GwsError::Validation(format!(
"Resource name must not contain path traversal ('..') segments: {s}"
)));
}
if s.contains('\0') || s.chars().any(|c| c.is_control()) {
return Err(GwsError::Validation(format!(
"Resource name contains invalid characters: {s}"
)));
}
// Reject URL-special characters that could inject query params or fragments
if s.contains('?') || s.contains('#') {
return Err(GwsError::Validation(format!(
"Resource name must not contain '?' or '#': {s}"
)));
}
// Reject '%' to prevent URL-encoded bypasses (e.g. %2e%2e for ..)
if s.contains('%') {
return Err(GwsError::Validation(format!(
"Resource name must not contain '%' (URL encoding bypass attempt): {s}"
)));
}
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
use tempfile::tempdir;
// --- validate_safe_output_dir ---
#[test]
#[serial]
fn test_output_dir_relative_subdir() {
// Create a real temp dir and change into it for the test
let dir = tempdir().unwrap();
// Canonicalize to handle macOS /var -> /private/var symlink
let canonical_dir = dir.path().canonicalize().unwrap();
let sub = canonical_dir.join("output");
fs::create_dir_all(&sub).unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_output_dir("output");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_ok(), "expected Ok, got: {result:?}");
}
#[test]
#[serial]
fn test_output_dir_rejects_symlink_traversal() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
// Create a directory inside the tempdir
let allowed_dir = canonical_dir.join("allowed");
fs::create_dir(&allowed_dir).unwrap();
// Create a symlink pointing OUTSIDE the tempdir (e.g. to /tmp)
let symlink_path = canonical_dir.join("sneaky_link");
#[cfg(unix)]
std::os::unix::fs::symlink("/tmp", &symlink_path).unwrap();
#[cfg(windows)]
return; // Skip on Windows due to privilege requirements for symlinks
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
// Try to validate the symlink resolving outside CWD
let result = validate_safe_output_dir("sneaky_link");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"), "got: {msg}");
}
#[test]
#[serial]
fn test_output_dir_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_output_dir("../../.ssh");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"), "got: {msg}");
}
#[test]
fn test_output_dir_rejects_absolute() {
assert!(validate_safe_output_dir("/tmp/evil").is_err());
}
#[test]
fn test_output_dir_rejects_null_bytes() {
assert!(validate_safe_output_dir("foo\0bar").is_err());
}
#[test]
fn test_output_dir_rejects_control_chars() {
assert!(validate_safe_output_dir("foo\x01bar").is_err());
}
#[test]
#[serial]
fn test_output_dir_non_existing_subdir() {
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_output_dir("new/nested/dir");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(
result.is_ok(),
"expected Ok for non-existing subdir, got: {result:?}"
);
}
// --- validate_safe_dir_path ---
#[test]
fn test_dir_path_cwd() {
assert!(validate_safe_dir_path(".").is_ok());
}
#[test]
#[serial]
fn test_dir_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_dir_path("../../etc");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err());
}
#[test]
fn test_dir_path_rejects_absolute() {
assert!(validate_safe_dir_path("/usr/local").is_err());
}
// --- reject_control_chars ---
#[test]
fn test_reject_control_chars_clean() {
assert!(reject_control_chars("hello/world", "test").is_ok());
}
#[test]
fn test_reject_control_chars_tab() {
assert!(reject_control_chars("hello\tworld", "test").is_err());
}
#[test]
fn test_reject_control_chars_newline() {
assert!(reject_control_chars("hello\nworld", "test").is_err());
}
// -- encode_path_segment --------------------------------------------------
#[test]
fn test_encode_path_segment_plain_id() {
assert_eq!(encode_path_segment("abc123"), "abc123");
}
#[test]
fn test_encode_path_segment_email() {
// Calendar IDs are often email addresses
let encoded = encode_path_segment("user@gmail.com");
assert!(!encoded.contains('@'));
assert!(!encoded.contains('.'));
}
#[test]
fn test_encode_path_segment_query_injection() {
// LLM might include query params in an ID by mistake
let encoded = encode_path_segment("fileid?fields=name");
assert!(!encoded.contains('?'));
assert!(!encoded.contains('='));
}
#[test]
fn test_encode_path_segment_fragment_injection() {
let encoded = encode_path_segment("fileid#section");
assert!(!encoded.contains('#'));
}
#[test]
fn test_encode_path_segment_path_traversal() {
// Encoding makes traversal segments harmless
let encoded = encode_path_segment("../../etc/passwd");
assert!(!encoded.contains('/'));
assert!(!encoded.contains(".."));
}
#[test]
fn test_encode_path_segment_unicode() {
// LLM might pass unicode characters
let encoded = encode_path_segment("日本語ID");
assert!(!encoded.contains('日'));
}
#[test]
fn test_encode_path_segment_spaces() {
let encoded = encode_path_segment("my file id");
assert!(!encoded.contains(' '));
}
#[test]
fn test_encode_path_segment_already_encoded() {
// LLM might double-encode by passing pre-encoded values
let encoded = encode_path_segment("user%40gmail.com");
// The % itself gets encoded to %25, so %40 becomes %2540
// This prevents double-encoding issues at the HTTP layer
assert!(encoded.contains("%2540"));
}
// -- validate_resource_name -----------------------------------------------
#[test]
fn test_validate_resource_name_valid() {
assert!(validate_resource_name("spaces/ABC123").is_ok());
assert!(validate_resource_name("subscriptions/my-sub").is_ok());
assert!(validate_resource_name("@default").is_ok());
assert!(validate_resource_name("projects/p1/topics/t1").is_ok());
}
#[test]
fn test_validate_resource_name_traversal() {
assert!(validate_resource_name("../../etc/passwd").is_err());
assert!(validate_resource_name("spaces/../other").is_err());
assert!(validate_resource_name("..").is_err());
}
#[test]
fn test_validate_resource_name_control_chars() {
assert!(validate_resource_name("spaces/\0bad").is_err());
assert!(validate_resource_name("spaces/\nbad").is_err());
assert!(validate_resource_name("spaces/\rbad").is_err());
assert!(validate_resource_name("spaces/\tbad").is_err());
}
#[test]
fn test_validate_resource_name_empty() {
assert!(validate_resource_name("").is_err());
}
#[test]
fn test_validate_resource_name_query_injection() {
// LLMs might append query strings or fragments to resource names
assert!(validate_resource_name("spaces/ABC?key=val").is_err());
assert!(validate_resource_name("spaces/ABC#fragment").is_err());
}
#[test]
fn test_validate_resource_name_error_messages_are_clear() {
let err = validate_resource_name("").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
let err = validate_resource_name("../bad").unwrap_err();
assert!(err.to_string().contains("path traversal"));
let err = validate_resource_name("bad\0id").unwrap_err();
assert!(err.to_string().contains("invalid characters"));
}
#[test]
fn test_validate_resource_name_percent_bypass() {
// %2e%2e is ..
assert!(validate_resource_name("%2e%2e").is_err());
assert!(validate_resource_name("spaces/%2e%2e/etc").is_err());
// Just % should be rejected too
assert!(validate_resource_name("spaces/100%").is_err());
}
}