fix(keystone): fail closed on EC2 auth and apply configured timeout (#4464)

This commit is contained in:
Zhengchao An
2026-07-08 18:31:48 +08:00
committed by GitHub
parent 7a33ba5e21
commit 9ffedc0e2b
9 changed files with 94 additions and 78 deletions
+40 -18
View File
@@ -120,24 +120,14 @@ impl KeystoneAuthProvider {
} }
/// Convert EC2 credential to Keystone token /// Convert EC2 credential to Keystone token
async fn ec2_to_keystone_token(&self, ec2_cred: &EC2Credential) -> Result<KeystoneToken> { ///
// In a real implementation, you'd need to: /// Fails closed. A correct implementation must resolve the full identity
// 1. Use admin credentials to get user/project details /// (user, project, roles, expiry) from Keystone using admin credentials or
// 2. Or maintain a mapping table /// a trusted mapping. It must never fabricate roles or derive identity from
// For simplicity, construct a minimal token /// client-supplied input. Until that is modeled against a live server, this
/// path refuses to mint a token.
Ok(KeystoneToken { async fn ec2_to_keystone_token(&self, _ec2_cred: &EC2Credential) -> Result<KeystoneToken> {
token: String::new(), Err(KeystoneError::Ec2AuthUnsupported)
user_id: ec2_cred.user_id.clone(),
username: ec2_cred.user_id.clone(), // Use user_id as username
project_id: ec2_cred.project_id.clone(),
project_name: ec2_cred.project_id.clone(),
domain_id: None,
domain_name: None,
roles: vec!["Member".to_string()], // Default role
expires_at: time::OffsetDateTime::now_utc() + time::Duration::hours(24),
issued_at: time::OffsetDateTime::now_utc(),
})
} }
/// Convert Keystone token to RustFS credentials /// Convert Keystone token to RustFS credentials
@@ -236,6 +226,7 @@ mod tests {
None, None,
"Default".to_string(), "Default".to_string(),
true, true,
Duration::from_secs(30),
); );
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true); let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true);
@@ -275,6 +266,7 @@ mod tests {
None, None,
"Default".to_string(), "Default".to_string(),
true, true,
Duration::from_secs(30),
); );
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true); let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true);
@@ -291,4 +283,34 @@ mod tests {
cred.groups = Some(vec!["Admin".to_string()]); cred.groups = Some(vec!["Admin".to_string()]);
assert!(provider.is_admin(&cred)); assert!(provider.is_admin(&cred));
} }
#[tokio::test]
async fn test_ec2_authentication_fails_closed() {
// The EC2/SigV4 path must never fabricate an identity from client input.
// It must fail closed rather than produce credentials.
let client = KeystoneClient::new(
"http://localhost:5000".to_string(),
crate::KeystoneVersion::V3,
None,
None,
None,
"Default".to_string(),
true,
Duration::from_secs(30),
);
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true).without_cache();
// A `:`-bearing access key previously shaped user_id/project_id. It must
// no longer produce any credentials.
let result = provider
.authenticate_with_ec2("attacker:admin-project", "sig", "string-to-sign")
.await;
assert!(result.is_err(), "EC2 auth must not return fabricated credentials");
assert!(
matches!(result, Err(KeystoneError::Ec2AuthUnsupported)),
"EC2 auth must fail closed with Ec2AuthUnsupported"
);
}
} }
+25 -45
View File
@@ -33,6 +33,8 @@ pub struct KeystoneClient {
admin_domain: String, admin_domain: String,
#[allow(dead_code)] #[allow(dead_code)]
verify_ssl: bool, verify_ssl: bool,
/// Request timeout applied to the underlying HTTP client.
timeout: std::time::Duration,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -49,6 +51,7 @@ impl AdminToken {
impl KeystoneClient { impl KeystoneClient {
/// Create new Keystone client /// Create new Keystone client
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
auth_url: String, auth_url: String,
version: KeystoneVersion, version: KeystoneVersion,
@@ -57,6 +60,7 @@ impl KeystoneClient {
admin_project: Option<String>, admin_project: Option<String>,
admin_domain: String, admin_domain: String,
verify_ssl: bool, verify_ssl: bool,
timeout: std::time::Duration,
) -> Self { ) -> Self {
if !verify_ssl { if !verify_ssl {
warn!( warn!(
@@ -67,7 +71,7 @@ impl KeystoneClient {
let client = Client::builder() let client = Client::builder()
.danger_accept_invalid_certs(!verify_ssl) .danger_accept_invalid_certs(!verify_ssl)
.timeout(std::time::Duration::from_secs(30)) .timeout(timeout)
.build() .build()
.unwrap(); .unwrap();
@@ -81,9 +85,15 @@ impl KeystoneClient {
admin_project, admin_project,
admin_domain, admin_domain,
verify_ssl, verify_ssl,
timeout,
} }
} }
/// Request timeout applied to the underlying HTTP client.
pub fn timeout(&self) -> std::time::Duration {
self.timeout
}
/// Validate a Keystone token /// Validate a Keystone token
pub async fn validate_token(&self, token: &str) -> Result<KeystoneToken> { pub async fn validate_token(&self, token: &str) -> Result<KeystoneToken> {
match self.version { match self.version {
@@ -209,53 +219,22 @@ impl KeystoneClient {
} }
/// Validate EC2 credentials /// Validate EC2 credentials
///
/// This path is intentionally not implemented and fails closed. A correct
/// implementation must derive the identity (user, project, roles, expiry)
/// from the Keystone `/v3/ec2tokens` response, not from client-supplied
/// input. The previous implementation discarded the response body and
/// fabricated an identity by splitting the client access key on `:`, which
/// let a caller shape their own user_id/project_id. Until the response
/// schema is modeled against a live server, refuse to produce credentials.
pub async fn validate_ec2_credentials( pub async fn validate_ec2_credentials(
&self, &self,
access_key: &str, _access_key: &str,
signature: &str, _signature: &str,
string_to_sign: &str, _string_to_sign: &str,
) -> Result<EC2Credential> { ) -> Result<EC2Credential> {
let url = format!("{}/v3/ec2tokens", self.auth_url); warn!("EC2/SigV4 credential authentication is not supported; refusing to fabricate identity from client input");
Err(KeystoneError::Ec2AuthUnsupported)
debug!("Validating EC2 credentials: access_key={}", access_key);
let payload = json!({
"auth": {
"identity": {
"methods": ["ec2"],
"ec2": {
"access": access_key,
"signature": signature,
"data": string_to_sign
}
}
}
});
let response = self
.client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| KeystoneError::HttpError(e.to_string()))?;
if !response.status().is_success() {
return Err(KeystoneError::InvalidCredentials);
}
let _body: serde_json::Value = response.json().await.map_err(|e| KeystoneError::ParseError(e.to_string()))?;
// Parse access key to extract user_id and project_id
let (user_id, project_id) = EC2Credential::parse_access_key(access_key).unwrap_or_else(|| (access_key.to_string(), None));
Ok(EC2Credential {
access: access_key.to_string(),
secret: String::new(), // Secret not returned in validation
user_id,
project_id,
trust_id: None,
})
} }
/// Get EC2 credentials for a user /// Get EC2 credentials for a user
@@ -436,6 +415,7 @@ mod tests {
Some("admin".to_string()), Some("admin".to_string()),
"Default".to_string(), "Default".to_string(),
true, true,
std::time::Duration::from_secs(30),
); );
assert_eq!(client.auth_url, "http://keystone:5000"); assert_eq!(client.auth_url, "http://keystone:5000");
+16
View File
@@ -268,6 +268,22 @@ mod tests {
assert!(!config.enable_tenant_prefix); assert!(!config.enable_tenant_prefix);
assert!(!config.implicit_tenants); assert!(!config.implicit_tenants);
assert_eq!(config.timeout_seconds, 99); assert_eq!(config.timeout_seconds, 99);
// The configured timeout must actually be forwarded to the
// client, not silently dropped in favour of a hardcoded value.
assert_eq!(config.get_timeout(), Duration::from_secs(99));
let client = crate::KeystoneClient::new(
config.auth_url.clone(),
KeystoneVersion::V3,
config.admin_user.clone(),
config.admin_password.clone(),
config.admin_project.clone(),
config.get_admin_domain(),
config.verify_ssl,
config.get_timeout(),
);
assert_eq!(client.timeout(), Duration::from_secs(99));
}, },
); );
} }
+7
View File
@@ -51,6 +51,13 @@ pub enum KeystoneError {
#[error("Unsupported Keystone version")] #[error("Unsupported Keystone version")]
UnsupportedVersion, UnsupportedVersion,
/// EC2/SigV4 credential authentication is not supported
///
/// The EC2 path deliberately fails closed rather than fabricating an
/// identity from client-supplied input.
#[error("EC2 credential authentication is not supported")]
Ec2AuthUnsupported,
/// Project not found /// Project not found
#[error("Project not found")] #[error("Project not found")]
ProjectNotFound, ProjectNotFound,
+1
View File
@@ -248,6 +248,7 @@ mod tests {
None, None,
"Default".to_string(), "Default".to_string(),
true, true,
std::time::Duration::from_secs(30),
); );
KeystoneIdentityMapper::new(Arc::new(client), true) KeystoneIdentityMapper::new(Arc::new(client), true)
} }
+1 -15
View File
@@ -40,6 +40,7 @@
//! config.admin_project.clone(), //! config.admin_project.clone(),
//! config.get_admin_domain(), //! config.get_admin_domain(),
//! config.verify_ssl, //! config.verify_ssl,
//! config.get_timeout(),
//! ); //! );
//! //!
//! let auth_provider = KeystoneAuthProvider::new( //! let auth_provider = KeystoneAuthProvider::new(
@@ -141,21 +142,6 @@ pub struct EC2Credential {
pub trust_id: Option<String>, pub trust_id: Option<String>,
} }
impl EC2Credential {
/// Parse access key to extract user_id and project_id
///
/// Format: "user_id:project_id" or "user_id"
pub fn parse_access_key(access_key: &str) -> Option<(String, Option<String>)> {
if access_key.contains(':') {
let parts: Vec<&str> = access_key.split(':').collect();
if parts.len() == 2 {
return Some((parts[0].to_string(), Some(parts[1].to_string())));
}
}
Some((access_key.to_string(), None))
}
}
/// Token cache for performance optimization /// Token cache for performance optimization
#[derive(Clone)] #[derive(Clone)]
pub struct TokenCache { pub struct TokenCache {
+1
View File
@@ -242,6 +242,7 @@ mod tests {
None, None,
"Default".to_string(), "Default".to_string(),
true, true,
Duration::from_secs(30),
); );
let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true); let provider = KeystoneAuthProvider::new(client, 100, Duration::from_secs(60), true);
let layer = KeystoneAuthLayer::new(Some(Arc::new(provider))); let layer = KeystoneAuthLayer::new(Some(Arc::new(provider)));
@@ -28,6 +28,7 @@ fn create_test_auth_provider() -> Arc<KeystoneAuthProvider> {
Some("admin".to_string()), Some("admin".to_string()),
"Default".to_string(), "Default".to_string(),
false, // Don't verify SSL for tests false, // Don't verify SSL for tests
std::time::Duration::from_secs(30),
); );
Arc::new(KeystoneAuthProvider::new(client, 1000, std::time::Duration::from_secs(300), true)) Arc::new(KeystoneAuthProvider::new(client, 1000, std::time::Duration::from_secs(300), true))
@@ -256,6 +257,7 @@ fn test_auth_provider_configuration() {
Some("test-project".to_string()), Some("test-project".to_string()),
"TestDomain".to_string(), "TestDomain".to_string(),
true, true,
std::time::Duration::from_secs(30),
); );
// Test with caching enabled // Test with caching enabled
+1
View File
@@ -66,6 +66,7 @@ pub async fn init_keystone_auth(config: KeystoneConfig) -> Result<(), Box<dyn st
config.admin_project.clone(), config.admin_project.clone(),
config.get_admin_domain(), config.get_admin_domain(),
config.verify_ssl, config.verify_ssl,
config.get_timeout(),
); );
let auth_provider = KeystoneAuthProvider::new(client.clone(), config.cache_size, config.get_cache_ttl(), config.enable_cache); let auth_provider = KeystoneAuthProvider::new(client.clone(), config.cache_size, config.get_cache_ttl(), config.enable_cache);