From 0aedd870ff308695cc1fec7168fd0fd8ede30518 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Thu, 14 May 2026 22:46:34 +0800 Subject: [PATCH 1/7] fix(drive): write files.download output --- crates/google-workspace-cli/src/executor.rs | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 46f31ac4..a33647d6 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -384,6 +384,50 @@ async fn handle_binary_response( Ok(None) } +fn extract_download_uri(json_val: &Value) -> Option<&str> { + [ + "/response/downloadUri", + "/response/downloadUrl", + "/metadata/downloadUri", + "/metadata/downloadUrl", + "/downloadUri", + "/downloadUrl", + ] + .into_iter() + .find_map(|path| json_val.pointer(path).and_then(|v| v.as_str())) +} + +fn is_google_download_uri(uri: &str) -> bool { + let Ok(url) = reqwest::Url::parse(uri) else { + return false; + }; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + + host == "storage.googleapis.com" + || host.ends_with(".googleapis.com") + || host.ends_with(".googleusercontent.com") +} + +fn extract_google_download_uri(body_text: &str) -> Result, GwsError> { + let Ok(json_val) = serde_json::from_str::(body_text) else { + return Ok(None); + }; + let Some(uri) = extract_download_uri(&json_val) else { + return Ok(None); + }; + if !is_google_download_uri(uri) { + return Err(GwsError::Validation( + "Refusing to follow non-Google downloadUri from API response".to_string(), + )); + } + Ok(Some(uri.to_string())) +} + /// Executes an API method call. /// /// This is the core function of the CLI that handles: @@ -495,6 +539,46 @@ pub async fn execute_method( .await .context("Failed to read response body")?; + if output_path.is_some() && method.id.as_deref() == Some("drive.files.download") { + if let Some(download_uri) = extract_google_download_uri(&body_text)? { + let mut download_request = client.get(download_uri); + if let Some(token) = token { + if auth_method == AuthMethod::OAuth { + download_request = download_request.bearer_auth(token); + } + } + let download_response = download_request + .send() + .await + .context("HTTP download request failed")?; + let download_status = download_response.status(); + let download_content_type = download_response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + if !download_status.is_success() { + let error_body = download_response.text().await.unwrap_or_default(); + return handle_error_response(download_status, &error_body, &auth_method); + } + + if let Some(res) = handle_binary_response( + download_response, + &download_content_type, + output_path, + output_format, + capture_output, + ) + .await? + { + captured_values.push(res); + } + break; + } + } + let should_continue = handle_json_response( &body_text, pagination, @@ -1209,6 +1293,35 @@ mod tests { assert_ne!(AuthMethod::OAuth, AuthMethod::None); } + #[test] + fn test_extract_download_uri_from_drive_operation_response() { + let operation = json!({ + "done": true, + "response": { + "downloadUri": "https://www.googleapis.com/download/drive/v3/files/abc?alt=media" + } + }); + + assert_eq!( + extract_download_uri(&operation), + Some("https://www.googleapis.com/download/drive/v3/files/abc?alt=media") + ); + } + + #[test] + fn test_extract_google_download_uri_rejects_non_google_url() { + let operation = json!({ + "done": true, + "response": { + "downloadUri": "https://example.com/file.csv" + } + }) + .to_string(); + + let err = extract_google_download_uri(&operation).unwrap_err(); + assert!(err.to_string().contains("non-Google downloadUri")); + } + #[test] fn test_mime_to_extension_more_types() { assert_eq!(mime_to_extension("text/plain"), "txt"); From 77b059bf1c7fd66be6e154ce0e92761a2d475fc6 Mon Sep 17 00:00:00 2001 From: Lubrsy Date: Fri, 15 May 2026 11:00:18 +0800 Subject: [PATCH 2/7] fix(drive): add quota project to downloads --- crates/google-workspace-cli/src/executor.rs | 39 ++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index a33647d6..963768ee 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -187,10 +187,7 @@ async fn build_http_request( } } - // Set quota project from ADC for billing/quota attribution - if let Some(quota_project) = crate::auth::get_quota_project() { - request = request.header("x-goog-user-project", quota_project); - } + request = add_quota_project_header(request); let mut all_query_params = input.query_params.clone(); if let Some(pt) = page_token { @@ -541,7 +538,7 @@ pub async fn execute_method( if output_path.is_some() && method.id.as_deref() == Some("drive.files.download") { if let Some(download_uri) = extract_google_download_uri(&body_text)? { - let mut download_request = client.get(download_uri); + let mut download_request = add_quota_project_header(client.get(download_uri)); if let Some(token) = token { if auth_method == AuthMethod::OAuth { download_request = download_request.bearer_auth(token); @@ -621,6 +618,14 @@ pub async fn execute_method( Ok(None) } +fn add_quota_project_header(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if let Some(quota_project) = crate::auth::get_quota_project() { + request.header("x-goog-user-project", quota_project) + } else { + request + } +} + fn build_url( doc: &RestDescription, method: &RestMethod, @@ -1322,6 +1327,30 @@ mod tests { assert!(err.to_string().contains("non-Google downloadUri")); } + #[test] + #[serial_test::serial] + fn test_add_quota_project_header_uses_configured_project() { + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "quota-project"); + } + + let request = add_quota_project_header(reqwest::Client::new().get("https://example.com")) + .build() + .unwrap(); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + } + + assert_eq!( + request + .headers() + .get("x-goog-user-project") + .and_then(|value| value.to_str().ok()), + Some("quota-project") + ); + } + #[test] fn test_mime_to_extension_more_types() { assert_eq!(mime_to_extension("text/plain"), "txt"); From 6a233a01ed01c0b6b9ab3ec6b0b0e39e0ccd9b59 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 11:52:11 +0800 Subject: [PATCH 3/7] fix(drive): keep headers on download requests --- crates/google-workspace-cli/src/executor.rs | 73 ++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 963768ee..8f9b115f 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -505,7 +505,10 @@ pub async fn execute_method( .to_string(); if !status.is_success() { - let error_body = response.text().await.unwrap_or_default(); + let error_body = response + .text() + .await + .context("Failed to read API error response body")?; tracing::warn!( api_method = method_id, http_method = %method.http_method, @@ -538,12 +541,8 @@ pub async fn execute_method( if output_path.is_some() && method.id.as_deref() == Some("drive.files.download") { if let Some(download_uri) = extract_google_download_uri(&body_text)? { - let mut download_request = add_quota_project_header(client.get(download_uri)); - if let Some(token) = token { - if auth_method == AuthMethod::OAuth { - download_request = download_request.bearer_auth(token); - } - } + let download_request = + build_download_request(&client, &download_uri, token, &auth_method); let download_response = download_request .send() .await @@ -557,7 +556,10 @@ pub async fn execute_method( .to_string(); if !download_status.is_success() { - let error_body = download_response.text().await.unwrap_or_default(); + let error_body = download_response + .text() + .await + .context("Failed to read Drive download error response body")?; return handle_error_response(download_status, &error_body, &auth_method); } @@ -626,6 +628,23 @@ fn add_quota_project_header(request: reqwest::RequestBuilder) -> reqwest::Reques } } +fn build_download_request( + client: &reqwest::Client, + download_uri: &str, + token: Option<&str>, + auth_method: &AuthMethod, +) -> reqwest::RequestBuilder { + // Keep secondary Drive downloads on the same reqwest client so they use + // the same client-level configuration as API requests. + let mut request = add_quota_project_header(client.get(download_uri)); + if let Some(token) = token { + if *auth_method == AuthMethod::OAuth { + request = request.bearer_auth(token); + } + } + request +} + fn build_url( doc: &RestDescription, method: &RestMethod, @@ -1351,6 +1370,44 @@ mod tests { ); } + #[test] + #[serial_test::serial] + fn test_build_download_request_keeps_client_auth_and_quota_headers() { + let client = reqwest::Client::new(); + + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "quota-project"); + } + + let request = build_download_request( + &client, + "https://www.googleapis.com/download/drive/v3/files/abc?alt=media", + Some("access-token"), + &AuthMethod::OAuth, + ) + .build() + .unwrap(); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + } + + assert_eq!( + request + .headers() + .get("x-goog-user-project") + .and_then(|value| value.to_str().ok()), + Some("quota-project") + ); + assert_eq!( + request + .headers() + .get(reqwest::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer access-token") + ); + } + #[test] fn test_mime_to_extension_more_types() { assert_eq!(mime_to_extension("text/plain"), "txt"); From 09d48c355ac3033ed6a47d4021d0a5739c8160a7 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 12:03:42 +0800 Subject: [PATCH 4/7] fix(drive): avoid bearer auth for signed downloads --- crates/google-workspace-cli/src/executor.rs | 50 ++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 8f9b115f..fcc3b26e 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -628,6 +628,19 @@ fn add_quota_project_header(request: reqwest::RequestBuilder) -> reqwest::Reques } } +fn is_signed_download_uri(download_uri: &str) -> bool { + reqwest::Url::parse(download_uri) + .map(|url| { + url.query_pairs().any(|(key, _)| { + let key = key.as_ref(); + key.eq_ignore_ascii_case("GoogleAccessId") + || key.eq_ignore_ascii_case("Signature") + || key.to_ascii_lowercase().starts_with("x-goog-") + }) + }) + .unwrap_or(false) +} + fn build_download_request( client: &reqwest::Client, download_uri: &str, @@ -638,7 +651,7 @@ fn build_download_request( // the same client-level configuration as API requests. let mut request = add_quota_project_header(client.get(download_uri)); if let Some(token) = token { - if *auth_method == AuthMethod::OAuth { + if *auth_method == AuthMethod::OAuth && !is_signed_download_uri(download_uri) { request = request.bearer_auth(token); } } @@ -1408,6 +1421,41 @@ mod tests { ); } + #[test] + #[serial_test::serial] + fn test_build_download_request_skips_bearer_for_signed_uri() { + let client = reqwest::Client::new(); + + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "quota-project"); + } + + let request = build_download_request( + &client, + "https://storage.googleapis.com/download/storage/v1/b/bucket/o/file?X-Goog-Signature=sig&X-Goog-Credential=credential", + Some("access-token"), + &AuthMethod::OAuth, + ) + .build() + .unwrap(); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + } + + assert_eq!( + request + .headers() + .get("x-goog-user-project") + .and_then(|value| value.to_str().ok()), + Some("quota-project") + ); + assert!(request + .headers() + .get(reqwest::header::AUTHORIZATION) + .is_none()); + } + #[test] fn test_mime_to_extension_more_types() { assert_eq!(mime_to_extension("text/plain"), "txt"); From 3e1fd5042717656cc6579b83a9fbcc861700beb2 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 12:33:49 +0800 Subject: [PATCH 5/7] fix(drive): only follow operation download URIs --- crates/google-workspace-cli/src/executor.rs | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index fcc3b26e..8e9ea201 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -394,6 +394,14 @@ fn extract_download_uri(json_val: &Value) -> Option<&str> { .find_map(|path| json_val.pointer(path).and_then(|v| v.as_str())) } +fn is_drive_download_operation(json_val: &Value) -> bool { + json_val.get("done").is_some() + || json_val + .get("kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == "drive#operation") +} + fn is_google_download_uri(uri: &str) -> bool { let Ok(url) = reqwest::Url::parse(uri) else { return false; @@ -414,6 +422,9 @@ fn extract_google_download_uri(body_text: &str) -> Result, GwsErr let Ok(json_val) = serde_json::from_str::(body_text) else { return Ok(None); }; + if !is_drive_download_operation(&json_val) { + return Ok(None); + } let Some(uri) = extract_download_uri(&json_val) else { return Ok(None); }; @@ -1345,6 +1356,32 @@ mod tests { ); } + #[test] + fn test_extract_google_download_uri_ignores_user_json_file_content() { + let file_content = json!({ + "downloadUri": "https://www.googleapis.com/download/drive/v3/files/abc?alt=media" + }) + .to_string(); + + assert_eq!(extract_google_download_uri(&file_content).unwrap(), None); + } + + #[test] + fn test_extract_google_download_uri_accepts_drive_operation_kind() { + let operation = json!({ + "kind": "drive#operation", + "response": { + "downloadUrl": "https://www.googleapis.com/download/drive/v3/files/abc?alt=media" + } + }) + .to_string(); + + assert_eq!( + extract_google_download_uri(&operation).unwrap(), + Some("https://www.googleapis.com/download/drive/v3/files/abc?alt=media".to_string()) + ); + } + #[test] fn test_extract_google_download_uri_rejects_non_google_url() { let operation = json!({ From 7f370b2564eae60631e0887f16e59743eab23e62 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 12:49:33 +0800 Subject: [PATCH 6/7] fix(drive): narrow download host allowlist --- crates/google-workspace-cli/src/executor.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 8e9ea201..a6236de6 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -413,9 +413,7 @@ fn is_google_download_uri(uri: &str) -> bool { return false; }; - host == "storage.googleapis.com" - || host.ends_with(".googleapis.com") - || host.ends_with(".googleusercontent.com") + host == "googleapis.com" || host.ends_with(".googleapis.com") } fn extract_google_download_uri(body_text: &str) -> Result, GwsError> { @@ -1396,6 +1394,20 @@ mod tests { assert!(err.to_string().contains("non-Google downloadUri")); } + #[test] + fn test_is_google_download_uri_allows_googleapis_hosts_only() { + assert!(is_google_download_uri("https://googleapis.com/download")); + assert!(is_google_download_uri( + "https://storage.googleapis.com/download/storage/v1/b/bucket/o/file" + )); + assert!(!is_google_download_uri( + "https://storage.googleapis.com.evil.example/file" + )); + assert!(!is_google_download_uri( + "https://drive.googleusercontent.com/file" + )); + } + #[test] #[serial_test::serial] fn test_add_quota_project_header_uses_configured_project() { From 4fb470a75e0741d13e0dc9b16ea9f3255f0bd8c1 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 16:47:27 +0800 Subject: [PATCH 7/7] fix(drive): tighten download URI security --- crates/google-workspace-cli/src/executor.rs | 84 ++++++++++++++++----- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index a6236de6..d4639105 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -395,25 +395,34 @@ fn extract_download_uri(json_val: &Value) -> Option<&str> { } fn is_drive_download_operation(json_val: &Value) -> bool { - json_val.get("done").is_some() - || json_val - .get("kind") - .and_then(Value::as_str) - .is_some_and(|kind| kind == "drive#operation") + json_val + .get("kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == "drive#operation") } -fn is_google_download_uri(uri: &str) -> bool { +fn parse_download_uri_host(uri: &str) -> Option { let Ok(url) = reqwest::Url::parse(uri) else { - return false; + return None; }; if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { - return false; - } - let Some(host) = url.host_str() else { - return false; + return None; }; + url.host_str().map(ToOwned::to_owned) +} - host == "googleapis.com" || host.ends_with(".googleapis.com") +fn is_google_download_uri(uri: &str) -> bool { + matches!( + parse_download_uri_host(uri).as_deref(), + Some("googleapis.com" | "www.googleapis.com" | "storage.googleapis.com") + ) +} + +fn is_google_api_download_host(uri: &str) -> bool { + matches!( + parse_download_uri_host(uri).as_deref(), + Some("googleapis.com" | "www.googleapis.com") + ) } fn extract_google_download_uri(body_text: &str) -> Result, GwsError> { @@ -658,12 +667,18 @@ fn build_download_request( ) -> reqwest::RequestBuilder { // Keep secondary Drive downloads on the same reqwest client so they use // the same client-level configuration as API requests. - let mut request = add_quota_project_header(client.get(download_uri)); - if let Some(token) = token { - if *auth_method == AuthMethod::OAuth && !is_signed_download_uri(download_uri) { - request = request.bearer_auth(token); + let mut request = client.get(download_uri); + let is_signed = is_signed_download_uri(download_uri); + + if !is_signed { + request = add_quota_project_header(request); + if let Some(token) = token { + if *auth_method == AuthMethod::OAuth && is_google_api_download_host(download_uri) { + request = request.bearer_auth(token); + } } } + request } @@ -1357,6 +1372,7 @@ mod tests { #[test] fn test_extract_google_download_uri_ignores_user_json_file_content() { let file_content = json!({ + "done": true, "downloadUri": "https://www.googleapis.com/download/drive/v3/files/abc?alt=media" }) .to_string(); @@ -1383,7 +1399,7 @@ mod tests { #[test] fn test_extract_google_download_uri_rejects_non_google_url() { let operation = json!({ - "done": true, + "kind": "drive#operation", "response": { "downloadUri": "https://example.com/file.csv" } @@ -1403,6 +1419,9 @@ mod tests { assert!(!is_google_download_uri( "https://storage.googleapis.com.evil.example/file" )); + assert!(!is_google_download_uri( + "https://attacker-bucket.storage.googleapis.com/file" + )); assert!(!is_google_download_uri( "https://drive.googleusercontent.com/file" )); @@ -1472,7 +1491,7 @@ mod tests { #[test] #[serial_test::serial] - fn test_build_download_request_skips_bearer_for_signed_uri() { + fn test_build_download_request_skips_headers_for_signed_uri() { let client = reqwest::Client::new(); unsafe { @@ -1492,6 +1511,35 @@ mod tests { std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); } + assert!(request.headers().get("x-goog-user-project").is_none()); + assert!(request + .headers() + .get(reqwest::header::AUTHORIZATION) + .is_none()); + } + + #[test] + #[serial_test::serial] + fn test_build_download_request_never_sends_bearer_to_storage_host() { + let client = reqwest::Client::new(); + + unsafe { + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "quota-project"); + } + + let request = build_download_request( + &client, + "https://storage.googleapis.com/download/storage/v1/b/bucket/o/file", + Some("access-token"), + &AuthMethod::OAuth, + ) + .build() + .unwrap(); + + unsafe { + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + } + assert_eq!( request .headers()