fix(security): harden proxy auth and default credentials (#2981)

* fix(security): harden proxy auth and default credentials

* fix(security): address proxy and credential feedback
This commit is contained in:
安正超
2026-05-16 12:01:50 +08:00
committed by GitHub
parent 824c4f7673
commit 6898e720dd
21 changed files with 374 additions and 115 deletions
+11 -2
View File
@@ -18,7 +18,7 @@ use crate::admin::handlers::site_replication::{
site_replication_bucket_meta_hook, site_replication_delete_bucket_hook, site_replication_make_bucket_hook,
};
use crate::app::context::{AppContext, default_notify_interface, get_global_app_context};
use crate::auth::get_condition_values;
use crate::auth::get_condition_values_with_client_info;
use crate::error::ApiError;
use crate::server::RemoteAddr;
use crate::storage::access::{ReqInfo, authorize_request, req_info_ref};
@@ -69,6 +69,7 @@ use rustfs_targets::{
EventName,
arn::{ARN, TargetIDError},
};
use rustfs_trusted_proxies::ClientInfo;
use rustfs_utils::http::{SUFFIX_FORCE_DELETE, get_header};
use rustfs_utils::obj::extract_user_defined_metadata;
use rustfs_utils::string::parse_bool;
@@ -1308,7 +1309,15 @@ impl DefaultBucketUsecase {
.map_err(ApiError::from)?;
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
let conditions = get_condition_values(&req.headers, &rustfs_credentials::Credentials::default(), None, None, remote_addr);
let client_info = req.extensions.get::<ClientInfo>();
let conditions = get_condition_values_with_client_info(
&req.headers,
&rustfs_credentials::Credentials::default(),
None,
None,
remote_addr,
client_info,
);
let read_allowed = PolicySys::is_allowed(&BucketPolicyArgs {
bucket: &bucket,
+95 -32
View File
@@ -20,9 +20,8 @@ use rustfs_iam::sys::{
SESSION_POLICY_NAME, get_claims_from_token_with_secret, get_claims_from_token_with_secret_allow_missing_exp,
};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use rustfs_utils::http::{
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER, ip::get_source_ip_raw,
};
use rustfs_trusted_proxies::ClientInfo;
use rustfs_utils::http::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER};
use s3s::S3Error;
use s3s::S3ErrorCode;
use s3s::S3Result;
@@ -32,6 +31,7 @@ use s3s::auth::SimpleAuth;
use s3s::s3_error;
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use subtle::ConstantTimeEq;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
@@ -436,6 +436,20 @@ pub(crate) fn extract_string_list_claim(claims: &HashMap<String, Value>, claim_n
}
}
fn policy_source_ip(remote_addr: Option<SocketAddr>, client_info: Option<&ClientInfo>) -> String {
client_info
.map(|info| info.real_ip.to_string())
.or_else(|| remote_addr.map(|addr| addr.ip().to_string()))
.unwrap_or_default()
}
fn policy_secure_transport(client_info: Option<&ClientInfo>) -> bool {
client_info
.and_then(|info| info.forwarded_proto.as_deref())
.map(|proto| proto.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
/// Get condition values for policy evaluation
///
/// # Arguments
@@ -453,9 +467,21 @@ pub fn get_condition_values(
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<std::net::SocketAddr>,
remote_addr: Option<SocketAddr>,
) -> HashMap<String, Vec<String>> {
get_condition_values_with_query(header, cred, version_id, region, remote_addr, None)
get_condition_values_with_client_info(header, cred, version_id, region, remote_addr, None)
}
/// Get condition values for policy evaluation with verified client information.
pub fn get_condition_values_with_client_info(
header: &HeaderMap,
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<SocketAddr>,
client_info: Option<&ClientInfo>,
) -> HashMap<String, Vec<String>> {
get_condition_values_with_query_and_client_info(header, cred, version_id, region, remote_addr, None, client_info)
}
/// Get condition values for policy evaluation with optional query-string values.
@@ -475,8 +501,22 @@ pub fn get_condition_values_with_query(
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<std::net::SocketAddr>,
remote_addr: Option<SocketAddr>,
query: Option<&str>,
) -> HashMap<String, Vec<String>> {
get_condition_values_with_query_and_client_info(header, cred, version_id, region, remote_addr, query, None)
}
/// Get condition values for policy evaluation with optional query-string values
/// and verified client information from trusted proxy middleware.
pub fn get_condition_values_with_query_and_client_info(
header: &HeaderMap,
cred: &Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<SocketAddr>,
query: Option<&str>,
client_info: Option<&ClientInfo>,
) -> HashMap<String, Vec<String>> {
let username = if cred.is_temp() || cred.is_service_account() {
cred.parent_user.clone()
@@ -510,21 +550,8 @@ pub fn get_condition_values_with_query(
// Determine auth type and signature version from headers and query
let (auth_type, signature_version) = determine_auth_type_and_version_with_query(header, query);
// Get TLS status from header
let is_tls = header
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s == "https")
.or_else(|| {
header
.get("x-forwarded-scheme")
.and_then(|v| v.to_str().ok())
.map(|s| s == "https")
})
.unwrap_or(false);
// Get remote address from header or use default
let remote_addr_s = remote_addr.map(|a| a.ip().to_string()).unwrap_or_default();
let is_tls = policy_secure_transport(client_info);
let source_ip = policy_source_ip(remote_addr, client_info);
let mut args = HashMap::new();
@@ -532,7 +559,7 @@ pub fn get_condition_values_with_query(
args.insert("CurrentTime".to_owned(), vec![curr_time.format(&Rfc3339).unwrap_or_default()]);
args.insert("EpochTime".to_owned(), vec![epoch_time.to_string()]);
args.insert("SecureTransport".to_owned(), vec![is_tls.to_string()]);
args.insert("SourceIp".to_owned(), vec![get_source_ip_raw(header, &remote_addr_s)]);
args.insert("SourceIp".to_owned(), vec![source_ip]);
// Add user agent and referer
if let Some(user_agent) = header.get("user-agent") {
@@ -888,6 +915,7 @@ mod tests {
use super::*;
use http::{HeaderMap, HeaderValue, Uri};
use rustfs_credentials::Credentials;
use rustfs_trusted_proxies::ValidationMode;
use s3s::auth::SecretKey;
use serde_json::json;
use std::collections::HashMap;
@@ -1600,32 +1628,32 @@ mod tests {
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 3: X-Forwarded-For present -> XFF (takes precedence over remote_addr)
// Case 3: X-Forwarded-For is ignored without verified proxy context
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.1");
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 4: X-Forwarded-For with multiple IPs -> First IP
// Case 4: X-Forwarded-For with multiple IPs is ignored without verified proxy context
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.3, 10.0.0.4"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.3");
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 5: X-Real-IP present (XFF removed) -> X-Real-IP
// Case 5: X-Real-IP is ignored without verified proxy context
headers.remove("x-forwarded-for");
headers.insert("x-real-ip", HeaderValue::from_static("10.0.0.2"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.2");
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 6: Forwarded header present (X-Real-IP removed) -> Forwarded
// Case 6: Forwarded is ignored without verified proxy context
headers.remove("x-real-ip");
headers.insert("forwarded", HeaderValue::from_static("for=10.0.0.5;proto=http"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.5");
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 7: Forwarded header with quotes and multiple values
// Case 7: Forwarded with quotes and multiple values is ignored without verified proxy context
headers.insert("forwarded", HeaderValue::from_static("for=\"10.0.0.6\", for=10.0.0.7"));
let conditions = get_condition_values(&headers, &cred, None, None, Some(remote_addr));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.6");
assert_eq!(conditions.get("SourceIp").unwrap()[0], "192.168.0.10");
// Case 8: IPv6 Remote Addr
let remote_addr_v6: std::net::SocketAddr = "[2001:db8::1]:8080".parse().unwrap();
@@ -1634,6 +1662,41 @@ mod tests {
assert_eq!(conditions.get("SourceIp").unwrap()[0], "2001:db8::1");
}
#[test]
fn test_get_condition_values_uses_verified_client_info() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
let cred = Credentials::default();
let remote_addr: std::net::SocketAddr = "192.168.0.10:12345".parse().unwrap();
let client_info = ClientInfo::from_trusted_proxy(
"10.0.0.1".parse().unwrap(),
None,
Some("https".to_string()),
"192.168.0.10".parse().unwrap(),
1,
ValidationMode::Lenient,
Vec::new(),
);
let conditions =
get_condition_values_with_client_info(&headers, &cred, None, None, Some(remote_addr), Some(&client_info));
assert_eq!(conditions.get("SourceIp").unwrap()[0], "10.0.0.1");
assert_eq!(conditions.get("SecureTransport").unwrap()[0], "true");
}
#[test]
fn test_get_condition_values_ignores_unverified_secure_transport_header() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
let cred = Credentials::default();
let conditions = get_condition_values(&headers, &cred, None, None, None);
assert_eq!(conditions.get("SecureTransport").unwrap()[0], "false");
}
// ========== KEYSTONE AUTHENTICATION TESTS ==========
#[tokio::test]
+9
View File
@@ -24,6 +24,7 @@ use rustfs_config::{
};
use rustfs_credentials::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, Masked};
use std::collections::HashSet;
use std::net::SocketAddr;
use std::sync::{Mutex, OnceLock};
pub(crate) const LEGACY_ENV_RUSTFS_ROOT_USER: &str = "RUSTFS_ROOT_USER";
@@ -174,6 +175,14 @@ impl Config {
}
}
pub fn is_using_default_credentials(&self) -> bool {
DEFAULT_ACCESS_KEY.eq(&self.access_key) && DEFAULT_SECRET_KEY.eq(&self.secret_key)
}
pub fn default_credentials_allowed_for_addr(&self, server_addr: SocketAddr, allow_insecure_defaults: bool) -> bool {
!self.is_using_default_credentials() || server_addr.ip().is_loopback() || allow_insecure_defaults
}
/// Create Config from Opt
pub(super) fn from_opt(opt: Opt) -> std::io::Result<Self> {
let Opt {
+18
View File
@@ -113,6 +113,24 @@ mod tests {
assert_eq!(config.buffer_profile, "GeneralPurpose");
}
#[test]
fn default_credentials_allowed_only_for_loopback_or_explicit_opt_in() {
let config = Config::new("0.0.0.0:9000", vec!["/tmp/rustfs-vol1".to_string()]);
assert!(!config.default_credentials_allowed_for_addr("0.0.0.0:9000".parse().unwrap(), false));
assert!(config.default_credentials_allowed_for_addr("127.0.0.1:9000".parse().unwrap(), false));
assert!(config.default_credentials_allowed_for_addr("0.0.0.0:9000".parse().unwrap(), true));
}
#[test]
fn custom_credentials_allowed_on_non_loopback() {
let mut config = Config::new("0.0.0.0:9000", vec!["/tmp/rustfs-vol1".to_string()]);
config.access_key = "custom-access-key".to_string();
config.secret_key = "custom-secret-key".to_string();
assert!(config.default_credentials_allowed_for_addr("0.0.0.0:9000".parse().unwrap(), false));
}
#[test]
#[serial]
fn test_custom_console_configuration() {
+23 -14
View File
@@ -72,7 +72,7 @@ use rustfs_ecstore::{
};
use rustfs_iam::init_iam_sys;
use rustfs_obs::{init_obs, set_global_guard};
use rustfs_utils::net::parse_and_resolve_address;
use rustfs_utils::{get_env_bool, net::parse_and_resolve_address};
use rustls::crypto::aws_lc_rs::default_provider;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::path::{Path, PathBuf};
@@ -303,19 +303,8 @@ impl RustFSServerBuilder {
// Trusted proxies.
rustfs_trusted_proxies::init();
// Credentials.
init_global_action_credentials(Some(config.access_key.clone()), Some(config.secret_key.clone()))
.map_err(|e| ServerError::Init(format!("credentials: {e:?}")))?;
// Region.
if let Some(region_str) = &config.region {
let region = region_str
.parse()
.map_err(|e| ServerError::Init(format!("invalid region '{region_str}': {e}")))?;
rustfs_ecstore::global::set_global_region(region);
}
// Resolve listen address.
// Resolve listen address before credential initialization so unsafe
// default credentials can fail before the server binds a listener.
let server_addr =
parse_and_resolve_address(config.address.as_str()).map_err(|e| ServerError::Init(format!("address: {e}")))?;
@@ -328,6 +317,26 @@ impl RustFSServerBuilder {
));
}
let allow_insecure_defaults = get_env_bool(rustfs_config::ENV_RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS, false);
if !config.default_credentials_allowed_for_addr(server_addr, allow_insecure_defaults) {
return Err(ServerError::Init(
"default root credentials are not allowed on non-loopback listeners; set access_key and secret_key to non-default values, bind to loopback, or set RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true for local development only"
.to_string(),
));
}
// Credentials.
init_global_action_credentials(Some(config.access_key.clone()), Some(config.secret_key.clone()))
.map_err(|e| ServerError::Init(format!("credentials: {e:?}")))?;
// Region.
if let Some(region_str) = &config.region {
let region = region_str
.parse()
.map_err(|e| ServerError::Init(format!("invalid region '{region_str}': {e}")))?;
rustfs_ecstore::global::set_global_region(region);
}
let server_port = server_addr.port();
set_global_rustfs_port(server_port);
+25 -15
View File
@@ -59,7 +59,7 @@ use rustfs_iam::{init_iam_sys, init_oidc_sys};
use rustfs_obs::{init_metrics_runtime, init_obs, set_global_guard};
use rustfs_scanner::init_data_scanner;
use rustfs_utils::{
ExternalEnvCompatReport, apply_external_env_compat, get_env_bool_with_aliases, net::parse_and_resolve_address,
ExternalEnvCompatReport, apply_external_env_compat, get_env_bool, get_env_bool_with_aliases, net::parse_and_resolve_address,
};
use rustls::crypto::aws_lc_rs::default_provider;
use std::io::{Error, Result};
@@ -138,11 +138,15 @@ fn format_external_prefix_mappings(report: &ExternalEnvCompatReport) -> String {
}
fn is_using_default_credentials(config: &rustfs::config::Config) -> bool {
rustfs_credentials::DEFAULT_ACCESS_KEY.eq(&config.access_key) && rustfs_credentials::DEFAULT_SECRET_KEY.eq(&config.secret_key)
config.is_using_default_credentials()
}
const DEFAULT_CREDENTIALS_WARNING_MESSAGE: &str =
"Detected default root credentials; change them with the RUSTFS_ACCESS_KEY and RUSTFS_SECRET_KEY environment variables";
const DEFAULT_CREDENTIALS_WARNING_MESSAGE: &str = "Detected default root credentials; set RUSTFS_ACCESS_KEY and RUSTFS_SECRET_KEY to non-default values, or use RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true only for local development";
const DEFAULT_CREDENTIALS_ERROR_MESSAGE: &str = "Default root credentials are not allowed on non-loopback listeners; set RUSTFS_ACCESS_KEY and RUSTFS_SECRET_KEY to non-default values, bind to loopback, or set RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true for local development only";
fn allow_insecure_default_credentials() -> bool {
get_env_bool(rustfs_config::ENV_RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS, false)
}
async fn async_main() -> Result<()> {
// Parse command line arguments
@@ -265,6 +269,15 @@ async fn run(config: rustfs::config::Config) -> Result<()> {
let server_port = server_addr.port();
let server_address = server_addr.to_string();
if !config.default_credentials_allowed_for_addr(server_addr, allow_insecure_default_credentials()) {
error!("{DEFAULT_CREDENTIALS_ERROR_MESSAGE}");
return Err(Error::other(DEFAULT_CREDENTIALS_ERROR_MESSAGE));
}
if is_using_default_credentials(&config) {
warn!("{}", DEFAULT_CREDENTIALS_WARNING_MESSAGE);
}
info!(
target: "rustfs::main::run",
server_address = %server_address,
@@ -360,10 +373,6 @@ async fn run(config: rustfs::config::Config) -> Result<()> {
None
};
if is_using_default_credentials(&config) {
warn!("{}", DEFAULT_CREDENTIALS_WARNING_MESSAGE);
}
let ctx = CancellationToken::new();
// init store
@@ -832,12 +841,13 @@ mod tests {
}
#[test]
fn default_credentials_warning_message_does_not_expose_values() {
let message = DEFAULT_CREDENTIALS_WARNING_MESSAGE;
assert!(message.contains(rustfs_config::ENV_RUSTFS_ACCESS_KEY));
assert!(message.contains(rustfs_config::ENV_RUSTFS_SECRET_KEY));
assert!(!message.contains(rustfs_credentials::DEFAULT_ACCESS_KEY));
assert!(!message.contains(rustfs_credentials::DEFAULT_SECRET_KEY));
fn default_credentials_messages_are_actionable_without_exposing_values() {
for message in [DEFAULT_CREDENTIALS_WARNING_MESSAGE, DEFAULT_CREDENTIALS_ERROR_MESSAGE] {
assert!(message.contains(rustfs_config::ENV_RUSTFS_ACCESS_KEY));
assert!(message.contains(rustfs_config::ENV_RUSTFS_SECRET_KEY));
assert!(message.contains(rustfs_config::ENV_RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS));
assert!(!message.contains(rustfs_credentials::DEFAULT_ACCESS_KEY));
assert!(!message.contains(rustfs_credentials::DEFAULT_SECRET_KEY));
}
}
}
+15 -4
View File
@@ -13,7 +13,7 @@
// limitations under the License.
use super::ecfs::FS;
use crate::auth::{check_key_valid, get_condition_values_with_query, get_session_token};
use crate::auth::{check_key_valid, get_condition_values_with_query_and_client_info, get_session_token};
use crate::error::ApiError;
use crate::license::license_check;
use crate::server::RemoteAddr;
@@ -30,6 +30,7 @@ use rustfs_policy::policy::{
Args, BucketPolicy, BucketPolicyArgs, bucket_policy_needs_existing_object_tag_for_args,
bucket_policy_uses_existing_object_tag_conditions,
};
use rustfs_trusted_proxies::ClientInfo;
use rustfs_utils::http::AMZ_OBJECT_LOCK_BYPASS_GOVERNANCE;
use s3s::access::{S3Access, S3AccessContext};
use s3s::{S3Error, S3ErrorCode, S3Request, S3Result, dto::*, s3_error};
@@ -334,8 +335,16 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
let default_claims = HashMap::new();
let claims = cred.claims.as_ref().unwrap_or(&default_claims);
let mut conditions =
get_condition_values_with_query(&req.headers, cred, version_id.as_deref(), None, remote_addr, req.uri.query());
let client_info = req.extensions.get::<ClientInfo>();
let mut conditions = get_condition_values_with_query_and_client_info(
&req.headers,
cred,
version_id.as_deref(),
None,
remote_addr,
req.uri.query(),
client_info,
);
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
let action_args = Args {
@@ -543,13 +552,15 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
}
} else {
let default_cred = rustfs_credentials::Credentials::default();
let mut conditions = get_condition_values_with_query(
let client_info = req.extensions.get::<ClientInfo>();
let mut conditions = get_condition_values_with_query_and_client_info(
&req.headers,
&default_cred,
version_id.as_deref(),
req.region.clone(),
remote_addr,
req.uri.query(),
client_info,
);
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);