fix(auth): validate --subscription in gmail +watch and deduplicate PUBSUB_API_BASE (#441)

* fix(auth): validate --subscription in gmail +watch and deduplicate PUBSUB_API_BASE

- Move PUBSUB_API_BASE constant to helpers/mod.rs (shared by events/subscribe and gmail/watch)
- Add validate_resource_name on --subscription in gmail +watch parse_watch_args
- Replace remaining hardcoded Pub/Sub and Gmail API URLs with constants
- Add test for --subscription path traversal rejection

Closes #408

* fix: replace remaining hardcoded Pub/Sub URLs in subscribe.rs with PUBSUB_API_BASE

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-12 14:26:36 -06:00
committed by GitHub
parent 3dcf818cf1
commit 86ea6dea32
4 changed files with 36 additions and 11 deletions
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
Validate `--subscription` resource name in `gmail +watch` and deduplicate `PUBSUB_API_BASE` constant.
+3 -4
View File
@@ -1,9 +1,8 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::helpers::PUBSUB_API_BASE;
use std::path::PathBuf;
const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
#[derive(Debug, Clone, Default, Builder)]
#[builder(setter(into))]
pub struct SubscribeConfig {
@@ -143,7 +142,7 @@ pub(super) async fn handle_subscribe(
// 1. Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {topic}");
let resp = client
.put(format!("https://pubsub.googleapis.com/v1/{topic}"))
.put(format!("{PUBSUB_API_BASE}/{topic}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.body("{}")
@@ -168,7 +167,7 @@ pub(super) async fn handle_subscribe(
"ackDeadlineSeconds": 60,
});
let resp = client
.put(format!("https://pubsub.googleapis.com/v1/{sub}"))
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&sub_body)
+22 -7
View File
@@ -1,7 +1,7 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::helpers::PUBSUB_API_BASE;
const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";
/// Handles the `+watch` command — Gmail push notifications via Pub/Sub.
@@ -50,7 +50,7 @@ pub(super) async fn handle_watch(
// Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {t}");
let resp = client
.put(format!("https://pubsub.googleapis.com/v1/{t}"))
.put(format!("{PUBSUB_API_BASE}/{t}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.body("{}")
@@ -79,7 +79,7 @@ pub(super) async fn handle_watch(
}
});
let resp = client
.post(format!("https://pubsub.googleapis.com/v1/{t}:setIamPolicy"))
.post(format!("{PUBSUB_API_BASE}/{t}:setIamPolicy"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&iam_body)
@@ -115,7 +115,7 @@ pub(super) async fn handle_watch(
"ackDeadlineSeconds": 60,
});
let resp = client
.put(format!("https://pubsub.googleapis.com/v1/{sub}"))
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(&pubsub_token)
.header("Content-Type", "application/json")
.json(&sub_body)
@@ -144,7 +144,7 @@ pub(super) async fn handle_watch(
}
let resp = client
.post("https://gmail.googleapis.com/gmail/v1/users/me/watch")
.post(format!("{GMAIL_API_BASE}/users/me/watch"))
.bearer_auth(&gmail_token)
.header("Content-Type", "application/json")
.json(&watch_body)
@@ -186,7 +186,7 @@ pub(super) async fn handle_watch(
// Get initial historyId for tracking
let profile_resp = client
.get("https://gmail.googleapis.com/gmail/v1/users/me/profile")
.get(format!("{GMAIL_API_BASE}/users/me/profile"))
.bearer_auth(&gmail_token)
.send()
.await
@@ -570,7 +570,13 @@ fn parse_watch_args(matches: &ArgMatches) -> Result<WatchConfig, GwsError> {
Ok(WatchConfig {
project: matches.get_one::<String>("project").cloned(),
subscription: matches.get_one::<String>("subscription").cloned(),
subscription: matches
.get_one::<String>("subscription")
.map(|s| {
crate::validate::validate_resource_name(s)?;
Ok::<_, GwsError>(s.clone())
})
.transpose()?,
topic: matches.get_one::<String>("topic").cloned(),
label_ids: matches.get_one::<String>("label-ids").cloned(),
max_messages: matches
@@ -787,6 +793,15 @@ mod tests {
assert!(msg.contains("outside the current directory"));
}
#[test]
fn test_parse_watch_args_rejects_traversal_subscription() {
let matches = make_matches_watch(&["test", "--subscription", "../../evil"]);
let result = parse_watch_args(&matches);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("path traversal"));
}
#[test]
fn test_parse_watch_args_full() {
let matches = make_matches_watch(&[
+6
View File
@@ -27,6 +27,12 @@ pub mod script;
pub mod sheets;
pub mod workflows;
/// Base URL for the Google Cloud Pub/Sub v1 API.
///
/// Shared across `events::subscribe` and `gmail::watch` so the constant
/// is defined in a single place.
pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
/// A trait for service-specific CLI helpers that inject custom commands.
pub trait Helper: Send + Sync {
/// Injects subcommands into the service command.