You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@reqsign.apache.org by xu...@apache.org on 2026/10/05 04:44:43 UTC
(reqsign) branch main updated: fix(aws): preserve quoted credential process arguments (#899)
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/reqsign.git
The following commit(s) were added to refs/heads/main by this push:
new 68f6aee fix(aws): preserve quoted credential process arguments (#899)
68f6aee is described below
commit 68f6aee456cbe613fb28cfc9ffd89270ef135951
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 5 12:44:40 2026 +0800
fix(aws): preserve quoted credential process arguments (#899)
Quoted executable paths and arguments in `credential_process` are
currently split at whitespace, and INI parsing can remove quotes or
consume Windows backslashes before execution.
Parse double-quoted argument boundaries in the AWS process provider and
preserve the raw command when loading configuration. Backslashes remain
literal; empty executable names and unmatched quotes fail before
execution. The command still goes directly to
`Context::command_execute(program, args)` without a shell or expansion.
Recording-executor regression tests cover direct commands and
default/named config profiles, including Windows paths, empty arguments,
malformed quotes, and literal shell syntax.
Fixes #895.
---
.../aws-core/src/provide_credential/process.rs | 232 +++++++++++++++++++--
1 file changed, 211 insertions(+), 21 deletions(-)
diff --git a/services/aws-core/src/provide_credential/process.rs b/services/aws-core/src/provide_credential/process.rs
index 2fe482f..17d410d 100644
--- a/services/aws-core/src/provide_credential/process.rs
+++ b/services/aws-core/src/provide_credential/process.rs
@@ -16,7 +16,7 @@
// under the License.
use crate::Credential;
-use ini::Ini;
+use ini::{Ini, ParseOption};
use log::debug;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use serde::Deserialize;
@@ -30,9 +30,14 @@ use serde::Deserialize;
/// Process credentials are typically configured in ~/.aws/config:
/// ```ini
/// [profile my-process-profile]
-/// credential_process = /path/to/credential/helper --arg1 value1
+/// credential_process = "/path/to/credential helper" --arg1 "value with spaces"
/// ```
///
+/// Double quotes preserve spaces in executable paths and arguments. Backslashes
+/// are literal, including in Windows paths. Commands are executed as a program
+/// and argument vector without shell expansion. Unmatched double quotes and an
+/// empty executable name are rejected before execution.
+///
/// # Output Format
/// The process must output JSON with the following structure:
/// ```json
@@ -112,8 +117,16 @@ impl ProcessCredentialProvider {
Error::config_invalid(format!("failed to read config file: {expanded_path}"))
})?;
- let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
- .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
+ // The command parser owns quoting; INI parsing must preserve Windows paths.
+ let conf = Ini::load_from_str_opt(
+ &String::from_utf8_lossy(&content),
+ ParseOption {
+ enabled_quote: false,
+ enabled_escape: false,
+ ..Default::default()
+ },
+ )
+ .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
let profile_section = if profile == "default" {
profile.to_string()
@@ -142,19 +155,12 @@ impl ProcessCredentialProvider {
) -> Result<ProcessCredentialOutput> {
debug!("executing credential process: {command}");
- // Parse command into program and arguments
- let parts: Vec<&str> = command.split_whitespace().collect();
- if parts.is_empty() {
- return Err(Error::config_invalid(
- "credential_process command is empty".to_string(),
- ));
- }
-
- let program = parts[0];
- let args = &parts[1..];
+ let parts = parse_command(command)?;
+ let program = parts[0].as_str();
+ let args: Vec<&str> = parts[1..].iter().map(String::as_str).collect();
// Execute the process using Context's command executor
- let output = ctx.command_execute(program, args).await?;
+ let output = ctx.command_execute(program, &args).await?;
if !output.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -182,6 +188,49 @@ impl ProcessCredentialProvider {
}
}
+// AWS uses double quotes to group whitespace, with literal backslashes for
+// Windows paths. Shell parsers would add escaping and single-quote semantics.
+fn parse_command(command: &str) -> Result<Vec<String>> {
+ let mut parts = Vec::new();
+ let mut part = String::new();
+ let mut quoted = false;
+ let mut started = false;
+
+ for ch in command.chars() {
+ match ch {
+ '"' => {
+ quoted = !quoted;
+ started = true;
+ }
+ ch if ch.is_whitespace() && !quoted => {
+ if started {
+ parts.push(std::mem::take(&mut part));
+ started = false;
+ }
+ }
+ ch => {
+ part.push(ch);
+ started = true;
+ }
+ }
+ }
+
+ if quoted {
+ return Err(Error::config_invalid(
+ "credential_process command has unmatched double quotes",
+ ));
+ }
+ if started {
+ parts.push(part);
+ }
+ if parts.first().is_none_or(String::is_empty) {
+ return Err(Error::config_invalid(
+ "credential_process command has an empty executable",
+ ));
+ }
+ Ok(parts)
+}
+
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ProcessCredentialOutput {
@@ -222,10 +271,11 @@ impl ProvideCredential for ProcessCredentialProvider {
mod tests {
use super::*;
use reqsign_command_execute_tokio::TokioCommandExecute;
- use reqsign_core::{OsEnv, StaticEnv};
+ use reqsign_core::{CommandExecute, CommandOutput, ErrorKind, FileRead, OsEnv, StaticEnv};
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
use std::collections::HashMap;
+ use std::sync::{Arc, Mutex};
#[tokio::test]
async fn test_process_provider_no_config() {
@@ -244,13 +294,153 @@ mod tests {
assert!(result.is_none());
}
+ #[derive(Debug, Clone, Default)]
+ struct RecordingCommandExecute {
+ calls: Arc<Mutex<Vec<Vec<String>>>>,
+ }
+
+ impl CommandExecute for RecordingCommandExecute {
+ async fn command_execute(&self, program: &str, args: &[&str]) -> Result<CommandOutput> {
+ self.calls.lock().unwrap().push(
+ std::iter::once(program)
+ .chain(args.iter().copied())
+ .map(str::to_string)
+ .collect(),
+ );
+ Ok(CommandOutput {
+ status: 0,
+ stdout: br#"{"Version":1,"AccessKeyId":"test_key","SecretAccessKey":"test_secret","SessionToken":"test_token","Expiration":"2030-01-01T00:00:00Z"}"#.to_vec(),
+ stderr: Vec::new(),
+ })
+ }
+ }
+
+ #[derive(Debug)]
+ struct ConfigFile(String);
+
+ impl FileRead for ConfigFile {
+ async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+ assert_eq!(path, "/aws/config");
+ Ok(self.0.as_bytes().to_vec())
+ }
+ }
+
+ // Exercise the same commands through both public configuration entrances.
+ fn command_context(
+ command: &str,
+ profile: Option<&str>,
+ executor: RecordingCommandExecute,
+ ) -> (ProcessCredentialProvider, Context) {
+ let provider = ProcessCredentialProvider::new();
+ let ctx = Context::new().with_command_execute(executor);
+ match profile {
+ None => (provider.with_command(command), ctx),
+ Some(profile) => {
+ let section = if profile == "default" {
+ "default".to_string()
+ } else {
+ format!("profile {profile}")
+ };
+ let ctx = ctx
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([("AWS_CONFIG_FILE".into(), "/aws/config".into())]),
+ })
+ .with_file_read(ConfigFile(format!(
+ "[{section}]\ncredential_process = {command}\n"
+ )));
+ (provider.with_profile(profile), ctx)
+ }
+ }
+ }
+
#[tokio::test]
- async fn test_process_provider_with_command() {
- let _provider = ProcessCredentialProvider::new()
- .with_command("echo '{\"Version\": 1, \"AccessKeyId\": \"test_key\", \"SecretAccessKey\": \"test_secret\"}'");
+ async fn test_process_command_arguments() {
+ let cases: &[(&str, &[&str])] = &[
+ ("helper", &["helper"]),
+ (" helper\t--role value ", &["helper", "--role", "value"]),
+ (
+ r#"credential-helper --role "role with spaces""#,
+ &["credential-helper", "--role", "role with spaces"],
+ ),
+ (
+ r#""/opt/credential tools/helper" plain "parameter with spaces""#,
+ &[
+ "/opt/credential tools/helper",
+ "plain",
+ "parameter with spaces",
+ ],
+ ),
+ (
+ r#""C:\Program Files\helper.exe" "C:\new folder\test\" C:\temp\file"#,
+ &[
+ r"C:\Program Files\helper.exe",
+ r"C:\new folder\test\",
+ r"C:\temp\file",
+ ],
+ ),
+ (r#"helper "" " " tail"#, &["helper", "", " ", "tail"]),
+ (
+ r#"helper --role="role with spaces""#,
+ &["helper", "--role=role with spaces"],
+ ),
+ (
+ r#"helper "$HOME" %USERPROFILE% "$(whoami)" "`whoami`" "*.json" "~" "|" ";" 'literal'"#,
+ &[
+ "helper",
+ "$HOME",
+ "%USERPROFILE%",
+ "$(whoami)",
+ "`whoami`",
+ "*.json",
+ "~",
+ "|",
+ ";",
+ "'literal'",
+ ],
+ ),
+ ];
+ for profile in [None, Some("default"), Some("example")] {
+ for (command, expected) in cases {
+ let executor = RecordingCommandExecute::default();
+ let (provider, ctx) = command_context(command, profile, executor.clone());
+ let credential = provider.provide_credential(&ctx).await.unwrap().unwrap();
+ assert_eq!(credential.access_key_id, "test_key");
+ assert_eq!(credential.secret_access_key, "test_secret");
+ assert_eq!(credential.session_token.as_deref(), Some("test_token"));
+ assert!(credential.expires_in.is_some());
+ assert_eq!(
+ *executor.calls.lock().unwrap(),
+ vec![expected.to_vec()],
+ "{command:?} via {profile:?}"
+ );
+ }
+ }
+ }
- // This test would need a real command that outputs valid JSON
- // In practice, you'd use a mock or test helper
+ #[tokio::test]
+ async fn test_invalid_process_commands_do_not_execute() {
+ for profile in [None, Some("default"), Some("example")] {
+ for command in [
+ "",
+ " ",
+ r#""""#,
+ r#""" arg"#,
+ r#""helper"#,
+ r#"helper "unfinished"#,
+ r#"helper value""#,
+ ] {
+ let executor = RecordingCommandExecute::default();
+ let (provider, ctx) = command_context(command, profile, executor.clone());
+ let error = provider.provide_credential(&ctx).await.unwrap_err();
+ assert_eq!(
+ error.kind(),
+ ErrorKind::ConfigInvalid,
+ "{command:?} via {profile:?}"
+ );
+ assert!(executor.calls.lock().unwrap().is_empty());
+ }
+ }
}
#[test]
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@reqsign.apache.org
For additional commands, e-mail: commits-help@reqsign.apache.org