mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-19 02:56:18 +00:00
fix(keystone): fail closed on EC2 auth and apply configured timeout (#4464)
This commit is contained in:
+40
-18
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user