diff --git a/aws-creds/src/credentials.rs b/aws-creds/src/credentials.rs index 020477b753..edbee4e286 100644 --- a/aws-creds/src/credentials.rs +++ b/aws-creds/src/credentials.rs @@ -12,7 +12,7 @@ use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; use std::time::Duration; use time::OffsetDateTime; -use url::Url; +use url::{Host, Url}; /// AWS access credentials: access key, secret key, and optional token. /// @@ -187,6 +187,48 @@ fn http_get(url: &str) -> attohttpc::Result { builder.send() } +// EKS Pod Identity Agent link-local addresses documented by AWS: +// https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html#pod-id-considerations +#[cfg(feature = "http-credentials")] +const EKS_POD_IDENTITY_AGENT_IPV4: [u8; 4] = [169, 254, 170, 23]; +#[cfg(feature = "http-credentials")] +const EKS_POD_IDENTITY_AGENT_IPV6: [u16; 8] = [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x23]; + +/// Reads the container authorization token from environment. +/// Checks `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` first (file path), then +/// `AWS_CONTAINER_AUTHORIZATION_TOKEN`. Used when fetching credentials from +/// `AWS_CONTAINER_CREDENTIALS_FULL_URI` (e.g. EKS Pod Identity Agent). +#[cfg(feature = "http-credentials")] +fn get_container_authorization_token() -> Result, CredentialsError> { + if let Ok(path) = env::var("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") { + let token = std::fs::read_to_string(path)?; + return Ok(Some(token.trim_end().to_string())); + } + Ok(env::var("AWS_CONTAINER_AUTHORIZATION_TOKEN").ok()) +} + +/// Returns whether it is safe to send a container authorization token to `url`. +#[cfg(feature = "http-credentials")] +fn validate_container_credentials_full_uri_for_auth_token( + url: &str, +) -> Result<(), CredentialsError> { + let parsed = Url::parse(url)?; + + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err(CredentialsError::InvalidContainerCredentialsFullUri( + url.to_string(), + )); + } + + match parsed.host() { + Some(Host::Ipv4(ip)) if ip.octets() == EKS_POD_IDENTITY_AGENT_IPV4 => Ok(()), + Some(Host::Ipv6(ip)) if ip.segments() == EKS_POD_IDENTITY_AGENT_IPV6 => Ok(()), + _ => Err(CredentialsError::InvalidContainerCredentialsFullUri( + url.to_string(), + )), + } +} + impl Credentials { pub fn refresh(&mut self) -> Result<(), CredentialsError> { if let Some(expiration) = self.expiration { @@ -327,18 +369,53 @@ impl Credentials { Credentials::from_env_specific(None, None, None, None) } + /// Load credentials from container metadata (ECS task role or EKS Pod Identity). + /// + /// Checks `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` first (ECS), then + /// `AWS_CONTAINER_CREDENTIALS_FULL_URI` (EKS Pod Identity Agent, etc.). + /// When using `FULL_URI`, optionally sends an `Authorization` header from + /// `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` or `AWS_CONTAINER_AUTHORIZATION_TOKEN`. + /// + /// # Example + /// + /// ```no_run + /// use awscreds::Credentials; + /// + /// // ECS task role credentials use a relative path on the ECS metadata host. + /// std::env::set_var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/task-role"); + /// let credentials = Credentials::from_container_credentials_provider()?; + /// + /// // EKS Pod Identity uses a full URI and may require an authorization token. + /// std::env::remove_var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"); + /// std::env::set_var( + /// "AWS_CONTAINER_CREDENTIALS_FULL_URI", + /// "http://169.254.170.23/v1/credentials", + /// ); + /// std::env::set_var("AWS_CONTAINER_AUTHORIZATION_TOKEN", "pod-identity-token"); + /// let credentials = Credentials::from_container_credentials_provider()?; + /// # Ok::<(), awscreds::error::CredentialsError>(()) + /// ``` #[cfg(feature = "http-credentials")] pub fn from_container_credentials_provider() -> Result { - let Ok(credentials_path) = env::var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") else { - return Err(CredentialsError::NotContainer); - }; + let (url, auth_token) = + if let Ok(relative_uri) = env::var("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") { + (format!("http://169.254.170.2{}", relative_uri), None) + } else if let Ok(full_uri) = env::var("AWS_CONTAINER_CREDENTIALS_FULL_URI") { + let token = get_container_authorization_token()?; + if token.is_some() { + validate_container_credentials_full_uri_for_auth_token(&full_uri)?; + } + (full_uri, token) + } else { + return Err(CredentialsError::NotContainer); + }; + + let mut request = apply_timeout(attohttpc::get(&url)); + if let Some(ref token) = auth_token { + request = request.header("Authorization", token.as_str()); + } - let resp: CredentialsFromInstanceMetadata = apply_timeout(attohttpc::get(format!( - "http://169.254.170.2{}", - credentials_path - ))) - .send()? - .json()?; + let resp: CredentialsFromInstanceMetadata = request.send()?.json()?; Ok(Credentials { access_key: Some(resp.access_key_id), @@ -510,8 +587,14 @@ struct CredentialsFromInstanceMetadata { mod tests { use super::*; use std::io::Write; + use std::sync::Mutex; + use std::sync::OnceLock; use tempfile::NamedTempFile; + /// Serializes container-credentials tests that touch RELATIVE_URI/FULL_URI so parallel runs don't race. + #[cfg(feature = "http-credentials")] + static CONTAINER_CREDENTIALS_TEST_LOCK: OnceLock> = OnceLock::new(); + fn create_test_credentials_file(content: &str) -> NamedTempFile { let mut file = NamedTempFile::new().unwrap(); file.write_all(content.as_bytes()).unwrap(); @@ -549,15 +632,11 @@ aws_access_key_id = ENV_KEY aws_secret_access_key = ENV_SECRET "#; let file = create_test_credentials_file(content); - - // Set the environment variable - env::set_var("AWS_SHARED_CREDENTIALS_FILE", file.path()); + let path = file.path().to_string_lossy().to_string(); + let _guard = EnvGuard::set("AWS_SHARED_CREDENTIALS_FILE", &path); let creds = Credentials::from_profile(None).unwrap(); assert_eq!(creds.access_key.unwrap(), "ENV_KEY"); - - // Clean up - env::remove_var("AWS_SHARED_CREDENTIALS_FILE"); } #[test] @@ -578,6 +657,235 @@ aws_secret_access_key = SECRET CredentialsError::ConfigNotFound )); } + + // Container credentials (RELATIVE_URI, FULL_URI, authorization token) - require http-credentials + #[cfg(feature = "http-credentials")] + #[test] + fn test_container_credentials_not_container_when_no_env() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let _guard = EnvGuard::remove(&[ + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + ]); + let result = Credentials::from_container_credentials_provider(); + assert!(matches!(result, Err(CredentialsError::NotContainer))); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_container_credentials_relative_uri_precedence() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let _guard = EnvGuard::set( + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "/v2/credentials/x", + ); + let _guard2 = EnvGuard::set( + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "http://169.254.170.23/v1/credentials", + ); + // With both set, RELATIVE_URI is used. Short timeout so request fails fast (no server). + let _timeout = set_request_timeout(Some(Duration::from_millis(10))); + let result = Credentials::from_container_credentials_provider(); + let _ = set_request_timeout(_timeout); + assert!(result.is_err()); + assert!(!matches!(result, Err(CredentialsError::NotContainer))); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_container_credentials_full_uri_used_when_no_relative() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let _guard = EnvGuard::set( + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "http://169.254.170.23/v1/credentials", + ); + let _rel = EnvGuard::remove(&["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"]); + // FULL_URI is used; short timeout so request fails fast (no server). + let _timeout = set_request_timeout(Some(Duration::from_millis(10))); + let result = Credentials::from_container_credentials_provider(); + let _ = set_request_timeout(_timeout); + assert!(result.is_err()); + assert!(!matches!(result, Err(CredentialsError::NotContainer))); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_get_container_authorization_token_from_file() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"token-from-file").unwrap(); + file.flush().unwrap(); + let path = file.path().to_string_lossy().to_string(); + let _guard = EnvGuard::set("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", &path); + let _u = EnvGuard::remove(&["AWS_CONTAINER_AUTHORIZATION_TOKEN"]); + assert_eq!( + get_container_authorization_token().unwrap(), + Some("token-from-file".to_string()) + ); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_get_container_authorization_token_file_error_does_not_fallback_to_env() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let _guard_file = EnvGuard::set( + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "/path/that/does/not/exist/token", + ); + let _guard_env = EnvGuard::set("AWS_CONTAINER_AUTHORIZATION_TOKEN", "token-from-env"); + + let result = get_container_authorization_token(); + assert!(matches!(result, Err(CredentialsError::Io(_)))); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_get_container_authorization_token_from_env() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let _guard = EnvGuard::set("AWS_CONTAINER_AUTHORIZATION_TOKEN", "token-from-env"); + let _u = EnvGuard::remove(&["AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"]); + assert_eq!( + get_container_authorization_token().unwrap(), + Some("token-from-env".to_string()) + ); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_get_container_authorization_token_file_precedence() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"token-from-file").unwrap(); + file.flush().unwrap(); + let path = file.path().to_string_lossy().to_string(); + let _guard_file = EnvGuard::set("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", &path); + let _guard_env = EnvGuard::set("AWS_CONTAINER_AUTHORIZATION_TOKEN", "token-from-env"); + // File takes precedence over env var. + assert_eq!( + get_container_authorization_token().unwrap(), + Some("token-from-file".to_string()) + ); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_container_credentials_rejects_auth_token_for_untrusted_full_uri() { + let _lock = CONTAINER_CREDENTIALS_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let _rel = EnvGuard::remove(&["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"]); + let _full = EnvGuard::set( + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "https://example.com/v1/credentials", + ); + let _token = EnvGuard::set("AWS_CONTAINER_AUTHORIZATION_TOKEN", "token-from-env"); + let _file = EnvGuard::remove(&["AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"]); + + let result = Credentials::from_container_credentials_provider(); + assert!(matches!( + result, + Err(CredentialsError::InvalidContainerCredentialsFullUri(_)) + )); + } + + #[cfg(feature = "http-credentials")] + #[test] + fn test_container_credentials_auth_token_full_uri_allowlist() { + assert!(validate_container_credentials_full_uri_for_auth_token( + "http://169.254.170.23/v1/credentials", + ) + .is_ok()); + assert!(validate_container_credentials_full_uri_for_auth_token( + "http://[fd00:ec2::23]/v1/credentials", + ) + .is_ok()); + assert!(matches!( + validate_container_credentials_full_uri_for_auth_token( + "http://169.254.170.24/v1/credentials", + ), + Err(CredentialsError::InvalidContainerCredentialsFullUri(_)) + )); + assert!(matches!( + validate_container_credentials_full_uri_for_auth_token( + "http://localhost/v1/credentials", + ), + Err(CredentialsError::InvalidContainerCredentialsFullUri(_)) + )); + assert!(matches!( + validate_container_credentials_full_uri_for_auth_token( + "http://127.0.0.1/v1/credentials", + ), + Err(CredentialsError::InvalidContainerCredentialsFullUri(_)) + )); + assert!(matches!( + validate_container_credentials_full_uri_for_auth_token( + "ftp://localhost/v1/credentials", + ), + Err(CredentialsError::InvalidContainerCredentialsFullUri(_)) + )); + } +} + +/// Restores env vars when dropped. Used in tests to avoid leaking env state. +#[cfg(test)] +struct EnvGuard { + saved: Vec<(String, Option)>, +} + +#[cfg(test)] +impl EnvGuard { + fn set(key: &str, value: &str) -> Self { + let saved = env::var(key).ok(); + env::set_var(key, value); + Self { + saved: vec![(key.to_string(), saved)], + } + } + fn remove(keys: &[&str]) -> Self { + let mut saved = Vec::with_capacity(keys.len()); + for key in keys { + let key = (*key).to_string(); + let val = env::var(&key).ok(); + env::remove_var(&key); + saved.push((key, val)); + } + Self { saved } + } +} + +#[cfg(test)] +impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.saved { + if let Some(ref v) = value { + env::set_var(key, v); + } else { + env::remove_var(key); + } + } + } } #[cfg(test)] diff --git a/aws-creds/src/error.rs b/aws-creds/src/error.rs index c5e93467a0..4183b1a884 100644 --- a/aws-creds/src/error.rs +++ b/aws-creds/src/error.rs @@ -6,6 +6,9 @@ pub enum CredentialsError { NotEc2, #[error("Not a container")] NotContainer, + #[cfg(feature = "http-credentials")] + #[error("invalid AWS_CONTAINER_CREDENTIALS_FULL_URI for authorization token: {0}")] + InvalidContainerCredentialsFullUri(String), #[error("Config not found")] ConfigNotFound, #[error("Missing aws_access_key_id section in config")]