From b2a4520cb9adf18e44cc6ed48385aba29c2d82ad Mon Sep 17 00:00:00 2001 From: David Bachhofer Date: Mon, 3 Aug 2026 21:17:58 +0200 Subject: [PATCH 1/3] fix(aws): send x-amz-security-token for temporary STS credentials --- src/providers/aws.rs | 45 +++++++++++++++++++----- src/providers/aws_tests.rs | 70 +++++++++++++++++++++++++++++++++++++- src/runtime/env.rs | 5 +++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/providers/aws.rs b/src/providers/aws.rs index d978aea7..031221a2 100644 --- a/src/providers/aws.rs +++ b/src/providers/aws.rs @@ -67,6 +67,11 @@ pub const AWS_REGION_GROUPS: &[(&str, usize, usize)] = &[ struct AwsCredentials { access_key: String, secret_key: String, + /// `aws_session_token` / `AWS_SESSION_TOKEN`. Present for temporary + /// credentials (access key IDs starting with `ASIA`) issued by STS via + /// AssumeRole, IAM Identity Center (SSO) or GetSessionToken. Must be sent + /// as a signed `x-amz-security-token` header or AWS rejects the request. + session_token: Option, } fn resolve_credentials( @@ -78,12 +83,17 @@ fn resolve_credentials( if !profile.is_empty() { return read_credentials_file(profile, env); } - // Token field: ACCESS_KEY_ID:SECRET_ACCESS_KEY - if let Some((ak, sk)) = token.split_once(':') { + // Token field: ACCESS_KEY_ID:SECRET_ACCESS_KEY[:SESSION_TOKEN] + if let Some((ak, rest)) = token.split_once(':') { + let (sk, st) = match rest.split_once(':') { + Some((sk, st)) if !st.is_empty() => (sk, Some(st.to_string())), + _ => (rest, None), + }; if !ak.is_empty() && !sk.is_empty() { return Ok(AwsCredentials { access_key: ak.to_string(), secret_key: sk.to_string(), + session_token: st, }); } } @@ -93,6 +103,7 @@ fn resolve_credentials( return Ok(AwsCredentials { access_key: ak.to_string(), secret_key: sk.to_string(), + session_token: env.aws_session_token().map(str::to_string), }); } } @@ -105,6 +116,7 @@ fn parse_credentials(content: &str, profile: &str) -> Option { let mut in_section = false; let mut access_key = String::new(); let mut secret_key = String::new(); + let mut session_token = String::new(); for line in content.lines() { let trimmed = line.trim(); @@ -119,6 +131,7 @@ fn parse_credentials(content: &str, profile: &str) -> Option { match key.trim() { "aws_access_key_id" => access_key = value.trim().to_string(), "aws_secret_access_key" => secret_key = value.trim().to_string(), + "aws_session_token" => session_token = value.trim().to_string(), _ => {} } } @@ -130,6 +143,7 @@ fn parse_credentials(content: &str, profile: &str) -> Option { Some(AwsCredentials { access_key, secret_key, + session_token: (!session_token.is_empty()).then_some(session_token), }) } } @@ -194,8 +208,21 @@ fn sign_request( datestamp: &str, ) -> String { let payload_hash = hex_encode(&sha256_hash(b"")); - let canonical_headers = format!("host:{}\nx-amz-date:{}\n", host, timestamp); - let signed_headers = "host;x-amz-date"; + // Canonical headers must be sorted by lowercase header name. With + // temporary credentials `x-amz-security-token` sorts after `x-amz-date`. + let (canonical_headers, signed_headers) = match &creds.session_token { + Some(token) => ( + format!( + "host:{}\nx-amz-date:{}\nx-amz-security-token:{}\n", + host, timestamp, token + ), + "host;x-amz-date;x-amz-security-token", + ), + None => ( + format!("host:{}\nx-amz-date:{}\n", host, timestamp), + "host;x-amz-date", + ), + }; let canonical_request = format!( "GET\n/\n{}\n{}\n{}\n{}", @@ -345,12 +372,14 @@ fn ec2_get( let auth = sign_request(creds, region, &host, &query_string, ×tamp, &datestamp); let url = format!("{}/?{}", endpoint, query_string); - let mut resp = agent + let mut req = agent .get(&url) .header("Authorization", &auth) - .header("x-amz-date", ×tamp) - .call() - .map_err(super::map_ureq_error)?; + .header("x-amz-date", ×tamp); + if let Some(token) = &creds.session_token { + req = req.header("x-amz-security-token", token); + } + let mut resp = req.call().map_err(super::map_ureq_error)?; resp.body_mut() .read_to_string() diff --git a/src/providers/aws_tests.rs b/src/providers/aws_tests.rs index 17df06bb..52f2d372 100644 --- a/src/providers/aws_tests.rs +++ b/src/providers/aws_tests.rs @@ -122,6 +122,7 @@ fn test_sign_request_format() { let creds = AwsCredentials { access_key: "AKIDEXAMPLE".to_string(), secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), + session_token: None, }; let auth = sign_request( &creds, @@ -143,6 +144,7 @@ fn test_sign_request_deterministic() { let creds = AwsCredentials { access_key: "AK".to_string(), secret_key: "SK".to_string(), + session_token: None, }; let a = sign_request( &creds, @@ -168,6 +170,7 @@ fn test_sign_request_different_regions() { let creds = AwsCredentials { access_key: "AK".to_string(), secret_key: "SK".to_string(), + session_token: None, }; let a = sign_request( &creds, @@ -231,10 +234,75 @@ fn test_parse_credentials_whitespace_handling() { #[test] fn test_parse_credentials_extra_keys_ignored() { - let content = "[default]\naws_access_key_id = AK\naws_secret_access_key = SK\naws_session_token = TOKEN\nregion = us-east-1\n"; + let content = "[default]\naws_access_key_id = AK\naws_secret_access_key = SK\nregion = us-east-1\n"; let creds = parse_credentials(content, "default").unwrap(); assert_eq!(creds.access_key, "AK"); assert_eq!(creds.secret_key, "SK"); + assert_eq!(creds.session_token, None); +} + +#[test] +fn test_parse_credentials_session_token() { + let content = "[default]\naws_access_key_id = ASIAEXAMPLE\naws_secret_access_key = SK\naws_session_token = TOKEN\n"; + let creds = parse_credentials(content, "default").unwrap(); + assert_eq!(creds.access_key, "ASIAEXAMPLE"); + assert_eq!(creds.secret_key, "SK"); + assert_eq!(creds.session_token.as_deref(), Some("TOKEN")); +} + +#[test] +fn test_sign_request_includes_security_token_when_present() { + let creds = AwsCredentials { + access_key: "ASIAEXAMPLE".to_string(), + secret_key: "SK".to_string(), + session_token: Some("TOKEN".to_string()), + }; + let auth = sign_request( + &creds, + "eu-central-1", + "ec2.eu-central-1.amazonaws.com", + "Action=DescribeInstances&Version=2016-11-15", + "20240101T000000Z", + "20240101", + ); + assert!(auth.contains("SignedHeaders=host;x-amz-date;x-amz-security-token,")); +} + +#[test] +fn test_sign_request_session_token_changes_signature() { + let base = AwsCredentials { + access_key: "ASIAEXAMPLE".to_string(), + secret_key: "SK".to_string(), + session_token: None, + }; + let with_token = AwsCredentials { + access_key: "ASIAEXAMPLE".to_string(), + secret_key: "SK".to_string(), + session_token: Some("TOKEN".to_string()), + }; + let args = ( + "eu-central-1", + "ec2.eu-central-1.amazonaws.com", + "Action=DescribeInstances", + "20240101T000000Z", + "20240101", + ); + let a = sign_request(&base, args.0, args.1, args.2, args.3, args.4); + let b = sign_request(&with_token, args.0, args.1, args.2, args.3, args.4); + assert_ne!(a, b); +} + +#[test] +fn test_resolve_credentials_token_with_session_token() { + let creds = resolve_credentials( + "ASIAEXAMPLE:SECRET:TOKEN", + "", + &crate::runtime::env::Env::empty(), + ) + .unwrap(); + assert_eq!(creds.access_key, "ASIAEXAMPLE"); + assert_eq!(creds.secret_key, "SECRET"); + assert_eq!(creds.session_token.as_deref(), Some("TOKEN")); } #[test] diff --git a/src/runtime/env.rs b/src/runtime/env.rs index 00752497..2de22514 100644 --- a/src/runtime/env.rs +++ b/src/runtime/env.rs @@ -208,6 +208,11 @@ impl Env { } } + /// `AWS_SESSION_TOKEN`, set alongside temporary STS credentials. + pub fn aws_session_token(&self) -> Option<&str> { + self.var("AWS_SESSION_TOKEN") + } + /// `PURPLE_TOKEN`, the self-invocation auth token. pub fn purple_token(&self) -> Option<&str> { self.var("PURPLE_TOKEN") From 435993e47c46d1fa76fcae99b67553304e729045 Mon Sep 17 00:00:00 2001 From: David Bachhofer Date: Wed, 5 Aug 2026 09:45:24 +0200 Subject: [PATCH 2/3] test(aws): cover env session token; fix fmt --- src/providers/aws_tests.rs | 38 +++++++++++++++++++++++++++++++++++++- src/runtime/env.rs | 17 +++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/providers/aws_tests.rs b/src/providers/aws_tests.rs index 52f2d372..3867f905 100644 --- a/src/providers/aws_tests.rs +++ b/src/providers/aws_tests.rs @@ -234,7 +234,8 @@ fn test_parse_credentials_whitespace_handling() { #[test] fn test_parse_credentials_extra_keys_ignored() { - let content = "[default]\naws_access_key_id = AK\naws_secret_access_key = SK\nregion = us-east-1\n"; + let content = + "[default]\naws_access_key_id = AK\naws_secret_access_key = SK\nregion = us-east-1\n"; let creds = parse_credentials(content, "default").unwrap(); assert_eq!(creds.access_key, "AK"); assert_eq!(creds.secret_key, "SK"); @@ -292,6 +293,41 @@ fn test_sign_request_session_token_changes_signature() { assert_ne!(a, b); } +#[test] +fn test_resolve_credentials_env_without_session_token() { + let env = crate::runtime::env::Env::for_test("/tmp/x") + .with_var("AWS_ACCESS_KEY_ID", "AKIDEXAMPLE") + .with_var("AWS_SECRET_ACCESS_KEY", "SECRET"); + let creds = resolve_credentials("", "", &env).unwrap(); + assert_eq!(creds.access_key, "AKIDEXAMPLE"); + assert_eq!(creds.secret_key, "SECRET"); + assert_eq!(creds.session_token, None); +} + +#[test] +fn test_resolve_credentials_env_with_session_token() { + let env = crate::runtime::env::Env::for_test("/tmp/x") + .with_var("AWS_ACCESS_KEY_ID", "ASIAEXAMPLE") + .with_var("AWS_SECRET_ACCESS_KEY", "SECRET") + .with_var("AWS_SESSION_TOKEN", "TOKEN"); + let creds = resolve_credentials("", "", &env).unwrap(); + assert_eq!(creds.access_key, "ASIAEXAMPLE"); + assert_eq!(creds.secret_key, "SECRET"); + assert_eq!(creds.session_token.as_deref(), Some("TOKEN")); +} + +#[test] +fn test_resolve_credentials_profile_shadows_env_session_token() { + // A configured profile returns early, so the env fallback never runs. + // With no readable credentials file this is an auth failure, not a + // silent fall-through to the environment. + let env = crate::runtime::env::Env::empty() + .with_var("AWS_ACCESS_KEY_ID", "ASIAEXAMPLE") + .with_var("AWS_SECRET_ACCESS_KEY", "SECRET") + .with_var("AWS_SESSION_TOKEN", "TOKEN"); + assert!(resolve_credentials("", "default", &env).is_err()); +} + #[test] fn test_resolve_credentials_token_with_session_token() { let creds = resolve_credentials( diff --git a/src/runtime/env.rs b/src/runtime/env.rs index 2de22514..5782c5c5 100644 --- a/src/runtime/env.rs +++ b/src/runtime/env.rs @@ -374,6 +374,23 @@ mod tests { assert_eq!(both.aws_credentials(), Some(("AKIA", "secret"))); } + #[test] + fn aws_session_token_reads_env_var() { + let env = Env::for_test("/tmp/x"); + assert_eq!(env.aws_session_token(), None); + let with_token = env.with_var("AWS_SESSION_TOKEN", "TOKEN"); + assert_eq!(with_token.aws_session_token(), Some("TOKEN")); + } + + #[test] + fn aws_session_token_is_independent_of_key_pair() { + // Temporary credentials always arrive as a triple, but the accessor + // must not depend on the key pair being present. + let env = Env::for_test("/tmp/x").with_var("AWS_SESSION_TOKEN", "TOKEN"); + assert_eq!(env.aws_credentials(), None); + assert_eq!(env.aws_session_token(), Some("TOKEN")); + } + #[test] fn active_proxy_vars_filters_empty_and_orders() { let env = Env::for_test("/tmp/x") From 2f7bbe7a9c81008737e7add9afbc6bbb5dbb643c Mon Sep 17 00:00:00 2001 From: Eric Kochen Date: Wed, 5 Aug 2026 20:01:16 +0200 Subject: [PATCH 3/3] fix(aws): keep the secret key intact on a trailing token separator A token written as ACCESS_KEY:SECRET: kept the trailing separator on the secret key, so every request signed with a key the account never issued. An empty third component now means no session token. Cover the security token on the wire. One test asserts the x-amz-security-token header reaches the endpoint for STS credentials, another asserts it stays absent for long-lived keys. Both fail when the header call in ec2_get is removed, which nothing caught before. The profile-over-environment test now writes a real credentials file into a tempdir and asserts every resolved field comes from it, so it fails if that precedence ever flips. --- src/providers/aws.rs | 3 +- src/providers/aws_tests.rs | 135 ++++++++++++++++++++++++++++++++++--- 2 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/providers/aws.rs b/src/providers/aws.rs index 031221a2..9ddde37e 100644 --- a/src/providers/aws.rs +++ b/src/providers/aws.rs @@ -87,7 +87,8 @@ fn resolve_credentials( if let Some((ak, rest)) = token.split_once(':') { let (sk, st) = match rest.split_once(':') { Some((sk, st)) if !st.is_empty() => (sk, Some(st.to_string())), - _ => (rest, None), + Some((sk, _)) => (sk, None), + None => (rest, None), }; if !ak.is_empty() && !sk.is_empty() { return Ok(AwsCredentials { diff --git a/src/providers/aws_tests.rs b/src/providers/aws_tests.rs index 3867f905..c3f9a6f6 100644 --- a/src/providers/aws_tests.rs +++ b/src/providers/aws_tests.rs @@ -318,14 +318,26 @@ fn test_resolve_credentials_env_with_session_token() { #[test] fn test_resolve_credentials_profile_shadows_env_session_token() { - // A configured profile returns early, so the env fallback never runs. - // With no readable credentials file this is an auth failure, not a - // silent fall-through to the environment. - let env = crate::runtime::env::Env::empty() - .with_var("AWS_ACCESS_KEY_ID", "ASIAEXAMPLE") - .with_var("AWS_SECRET_ACCESS_KEY", "SECRET") - .with_var("AWS_SESSION_TOKEN", "TOKEN"); - assert!(resolve_credentials("", "default", &env).is_err()); + // A configured profile wins over the environment, so every field comes + // from the credentials file even when both sources are populated. + let home = tempfile::tempdir().expect("tempdir"); + let aws_dir = home.path().join(".aws"); + std::fs::create_dir_all(&aws_dir).expect("create .aws"); + std::fs::write( + aws_dir.join("credentials"), + "[default]\naws_access_key_id = ASIAFROMFILE\naws_secret_access_key = FILESECRET\naws_session_token = FILETOKEN\n", + ) + .expect("write credentials file"); + + let env = crate::runtime::env::Env::for_test(home.path()) + .with_var("AWS_ACCESS_KEY_ID", "ASIAFROMENV") + .with_var("AWS_SECRET_ACCESS_KEY", "ENVSECRET") + .with_var("AWS_SESSION_TOKEN", "ENVTOKEN"); + + let creds = resolve_credentials("", "default", &env).expect("profile resolves from file"); + assert_eq!(creds.access_key, "ASIAFROMFILE"); + assert_eq!(creds.secret_key, "FILESECRET"); + assert_eq!(creds.session_token.as_deref(), Some("FILETOKEN")); } #[test] @@ -341,6 +353,17 @@ fn test_resolve_credentials_token_with_session_token() { assert_eq!(creds.session_token.as_deref(), Some("TOKEN")); } +#[test] +fn test_resolve_credentials_token_trailing_separator() { + // An empty third component leaves the secret key intact and yields no + // session token. + let creds = resolve_credentials("AKID:SECRET:", "", &crate::runtime::env::Env::empty()) + .expect("access key and secret are both present"); + assert_eq!(creds.access_key, "AKID"); + assert_eq!(creds.secret_key, "SECRET"); + assert_eq!(creds.session_token, None); +} + #[test] fn test_parse_credentials_empty_content() { assert!(parse_credentials("", "default").is_none()); @@ -1070,3 +1093,99 @@ fn fetch_from_maps_auth_failure_to_provider_error() { "a 401 from the region must surface as AuthFailed, got {result:?}" ); } + +#[test] +fn fetch_sends_security_token_header_for_temporary_credentials() { + // Both EC2 calls must carry x-amz-security-token, because the signature + // declares it in SignedHeaders. + let mut server = mockito::Server::new(); + let instances = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded( + "Action".into(), + "DescribeInstances".into(), + )) + .match_header("x-amz-security-token", "TOKEN") + .with_status(200) + .with_header("content-type", "text/xml") + .with_body( + r#" + + i-1234567890 + running + 54.1.2.3 + ami-12345678 + Nameweb-1 + +"#, + ) + .create(); + let images = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded( + "Action".into(), + "DescribeImages".into(), + )) + .match_header("x-amz-security-token", "TOKEN") + .with_status(200) + .with_header("content-type", "text/xml") + .with_body( + r#" + ami-12345678amzn2-ami-hvm-2.0 +"#, + ) + .create(); + + let aws = Aws { + regions: vec!["us-east-1".to_string()], + profile: String::new(), + }; + let url = server.url(); + let hosts = aws + .fetch_with_endpoint( + |_region| url.clone(), + "ASIAEXAMPLE:SECRET:TOKEN", + &AtomicBool::new(false), + &crate::runtime::env::Env::empty(), + &|_| {}, + ) + .expect("a signed request carrying the session token must reach the mock"); + instances.assert(); + images.assert(); + assert_eq!(hosts.len(), 1); +} + +#[test] +fn fetch_omits_security_token_header_for_static_credentials() { + // Long-lived keys sign without the token, so the header must be absent. + let mut server = mockito::Server::new(); + let instances = server + .mock("GET", "/") + .match_query(mockito::Matcher::Any) + .match_header("x-amz-security-token", mockito::Matcher::Missing) + .with_status(200) + .with_header("content-type", "text/xml") + .with_body( + r#" + +"#, + ) + .create(); + + let aws = Aws { + regions: vec!["us-east-1".to_string()], + profile: String::new(), + }; + let url = server.url(); + let hosts = aws + .fetch_with_endpoint( + |_region| url.clone(), + "AKID:SECRET", + &AtomicBool::new(false), + &crate::runtime::env::Env::empty(), + &|_| {}, + ) + .expect("static credentials must reach the mock without a token header"); + instances.assert(); + assert!(hosts.is_empty()); +}