Skip to content
Open
211 changes: 196 additions & 15 deletions aws-creds/src/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ fn http_get(url: &str) -> attohttpc::Result<attohttpc::Response> {
builder.send()
}

/// 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() -> Option<String> {
if let Ok(path) = env::var("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") {
if let Ok(token) = std::fs::read_to_string(path) {
return Some(token.trim_end().to_string());
}
}
env::var("AWS_CONTAINER_AUTHORIZATION_TOKEN").ok()
}
Comment on lines +210 to +230

@coderabbitai coderabbitai Bot May 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

AWS container credential provider FULL_URI allowed hosts loopback HTTPS policy Java SDK Go SDK botocore

💡 Result:

The AWS Container Credential Provider uses the AWS_CONTAINER_CREDENTIALS_FULL_URI environment variable to specify a full HTTP/HTTPS endpoint for fetching credentials in container environments like ECS and EKS. Due to security policies across AWS SDKs, this URI is restricted: For HTTP URIs: Only loopback hosts (localhost, 127.0.0.1, ::1) and specific AWS service endpoints (e.g., ECS 169.254.170.2, EKS IPv6 like fd00:ec2::23) are allowed. Non-loopback HTTP URIs are rejected. For HTTPS URIs: All hosts are permitted. This policy is enforced consistently: - Java SDK v1 (1.x): Only loopback hosts for HTTP; all HTTPS allowed. Docs explicitly state: "For absolute paths, only loopback hosts are allowed when using HTTP... All HTTPS endpoints are allowed." {1,5} - Java SDK v2 (2.x): Validates host resolves to loopback addresses for HTTP (via InetAddress check); HTTPS unrestricted. Source code confirms: if not HTTPS and not allowed host (loopback rules), throws error. {20} - Go SDK (v1 and v2): Follows same standard; supports FULL_URI with loopback/known endpoints for HTTP. {14,15} - Botocore (Python/CLI): ContainerMetadataFetcher._ALLOWED_HOSTS limits to ['169.254.170.2', 'localhost', '127.0.0.1'] for FULL_URI; cannot extend due to org-wide security policy. {2,16} This is a deliberate cross-SDK security measure to prevent arbitrary remote credential fetches, as confirmed in GitHub issues. {2,4} No configuration to override allowed hosts; use relative URI (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) with default ECS host or ensure HTTPS/loopback compliance. IPv6 loopback supported in recent versions. {13,23}

Citations:


validate_container_credentials_full_uri_for_auth_token allowlist is narrower than AWS SDK standard.

The current implementation only permits 169.254.170.23 (IPv4) and fd00:ec2::23 (IPv6), rejecting https://any-host, http://localhost, and http://127.0.0.1 when a token is present. This deviates from AWS SDK behavior across Java, Go, and Python:

  • Java SDK v1/v2: "For absolute paths, only loopback hosts are allowed when using HTTP... All HTTPS endpoints are allowed."
  • Go SDK: Supports loopback and known endpoints for HTTP; all hosts for HTTPS
  • Botocore: Allows ['169.254.170.2', 'localhost', '127.0.0.1'] for FULL_URI

This breaks:

  1. Custom HTTPS credential providers (IoT Greengrass, custom auth servers)
  2. Local testing with auth tokens via localhost

Correct policy: HTTPS → allow any host (transport encrypted); HTTP → restrict to loopback (127.0.0.1, ::1, localhost) and known EKS/ECS link-local IPs.

🛡️ Suggested allowlist expansion
 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(),
         ));
     }

+    // All HTTPS endpoints are trusted (transport security covers the token).
+    if parsed.scheme() == "https" {
+        return Ok(());
+    }
+
+    // For HTTP, restrict to loopback and the known EKS/ECS link-local IPs.
     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(()),
+        Some(Host::Ipv4(ip)) if ip.is_loopback() => Ok(()),
+        Some(Host::Ipv6(ip)) if ip.is_loopback() => Ok(()),
+        Some(Host::Domain(d)) if d == "localhost" => Ok(()),
         _ => Err(CredentialsError::InvalidContainerCredentialsFullUri(
             url.to_string(),
         )),
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@aws-creds/src/credentials.rs` around lines 210 - 230, The
validate_container_credentials_full_uri_for_auth_token function currently only
allows specific EKS/ECS addresses; update its logic so that for HTTPS
(parsed.scheme() == "https") it accepts any host, and for HTTP (parsed.scheme()
== "http") it only allows loopback and known link-local/EKS addresses: accept
Host::Ipv4 where ip.octets() equals EKS_POD_IDENTITY_AGENT_IPV4 or equals
127.0.0.1, accept Host::Ipv6 where ip.segments() equals
EKS_POD_IDENTITY_AGENT_IPV6 or equals the ::1 segments, and accept Host::Domain
where the domain equals "localhost"; otherwise return
CredentialsError::InvalidContainerCredentialsFullUri(url.to_string()) as before.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wholeheartedly disagree. The PRs you reference are people asking for support to point to other URIs for the credentials address, for which in all 3 PRs AWS employees say the same answer - that's it's against an org-wide (AWS-wide) security policy to allow that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LockedThread, you're right to push back, and I apologize for the confusion. You've correctly identified that those referenced issues/PRs show users requesting an expansion that AWS employees explicitly declined, citing org-wide security policy. I misread the direction of those conversations.

Looking at the actual AWS Java SDK documentation more carefully, the policy is: "For absolute paths, only loopback hosts are allowed when using HTTP, including known endpoints for ECS and EKS. All HTTPS endpoints are allowed." The Java SDK v2 enforces the same rule, rejecting endpoints where the "host should resolve to a loopback address or have the full URI be HTTPS."

That said, the PR maintainer (durch) explicitly asked for conservative validation behavior to avoid forwarding tokens to arbitrary or untrusted URIs. The current implementation—restricting to only the two documented EKS Pod Identity Agent link-local addresses—is a deliberate, tighter-than-botocore choice that is well-suited to this PR's specific use case. Since Amazon EKS sets AWS_CONTAINER_CREDENTIALS_FULL_URI and AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE environment variables pointing to those exact addresses, the current allowlist correctly covers the intended EKS Pod Identity scenario without over-broadening the attack surface.

I withdraw my suggestion to expand the allowlist. The current implementation is a valid and defensible conservative choice.


✏️ Learnings added
Learnt from: LockedThread
Repo: durch/rust-s3 PR: 449
File: aws-creds/src/credentials.rs:210-230
Timestamp: 2026-05-04T20:38:34.950Z
Learning: In `aws-creds/src/credentials.rs` (rust-s3 repo), the `validate_container_credentials_full_uri_for_auth_token` function intentionally restricts authorization token forwarding to only the two documented EKS Pod Identity Agent link-local addresses: `169.254.170.23` (IPv4) and `fd00:ec2::23` (IPv6). This is a deliberately conservative policy requested by the maintainer (`durch`) to avoid forwarding tokens to arbitrary/untrusted URIs. Do not suggest expanding this allowlist to include loopback addresses or all HTTPS endpoints.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😆


impl Credentials {
pub fn refresh(&mut self) -> Result<(), CredentialsError> {
if let Some(expiration) = self.expiration {
Expand Down Expand Up @@ -327,18 +341,30 @@ 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`.
#[cfg(feature = "http-credentials")]
pub fn from_container_credentials_provider() -> Result<Credentials, CredentialsError> {
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();
(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());
}
Comment thread
LockedThread marked this conversation as resolved.

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),
Expand Down Expand Up @@ -510,8 +536,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<Mutex<()>> = OnceLock::new();

fn create_test_credentials_file(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
Expand Down Expand Up @@ -549,15 +581,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]
Expand All @@ -578,6 +606,159 @@ 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(),
Some("token-from-file".to_string())
);
}

#[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(),
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(),
Some("token-from-file".to_string())
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Restores env vars when dropped. Used in tests to avoid leaking env state.
#[cfg(test)]
struct EnvGuard {
saved: Vec<(String, Option<String>)>,
}

#[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)]
Expand Down