mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-05 04:47:43 +00:00
fix(security): harden CORS and license handling (#2774)
This commit is contained in:
+36
-14
@@ -13,11 +13,11 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::handlers::health::{HealthProbe, build_health_payload, collect_dependency_readiness, health_check_state};
|
||||
use crate::license::get_license;
|
||||
use crate::license::has_valid_license;
|
||||
use crate::server::{CONSOLE_PREFIX, FAVICON_PATH, HEALTH_PREFIX, HEALTH_READY_PATH, LICENSE, RUSTFS_ADMIN_PREFIX, VERSION};
|
||||
use crate::version::build;
|
||||
use axum::{
|
||||
Router,
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::Request,
|
||||
middleware,
|
||||
@@ -241,20 +241,17 @@ pub(crate) fn init_console_cfg(local_ip: IpAddr, port: u16) {
|
||||
});
|
||||
}
|
||||
|
||||
/// License handler
|
||||
/// Returns the current license information of the console.
|
||||
///
|
||||
/// # Returns:
|
||||
/// - 200 OK with JSON body containing license details.
|
||||
#[derive(Serialize)]
|
||||
struct LicensePublicStatus {
|
||||
licensed: bool,
|
||||
}
|
||||
|
||||
/// Returns coarse public license status without exposing license metadata.
|
||||
#[instrument]
|
||||
async fn license_handler() -> impl IntoResponse {
|
||||
let license = get_license().unwrap_or_default();
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", "application/json")
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(serde_json::to_string(&license).unwrap_or_default()))
|
||||
.unwrap()
|
||||
Json(LicensePublicStatus {
|
||||
licensed: has_valid_license(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the given IP address is a private IP
|
||||
@@ -678,6 +675,7 @@ mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use temp_env::async_with_vars;
|
||||
use tower::ServiceExt;
|
||||
@@ -757,4 +755,28 @@ mod tests {
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn console_license_route_returns_public_status_only() {
|
||||
let app = setup_console_middleware_stack(parse_cors_origins(None), false, 0, 30);
|
||||
let request = Request::builder()
|
||||
.uri(format!("{CONSOLE_PREFIX}{LICENSE}"))
|
||||
.body(Body::empty())
|
||||
.expect("failed to build license request");
|
||||
|
||||
let response = app.oneshot(request).await.expect("license request should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("license body should collect")
|
||||
.to_bytes();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("license response should be valid JSON");
|
||||
|
||||
assert_eq!(value, serde_json::json!({ "licensed": false }));
|
||||
assert!(value.get("name").is_none());
|
||||
assert!(value.get("expired").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+57
-3
@@ -12,8 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_appauth::token::Token;
|
||||
use rustfs_appauth::token::parse_license;
|
||||
use rustfs_appauth::token::{Token, parse_license_with_public_key};
|
||||
use std::fmt;
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
use std::sync::Arc;
|
||||
@@ -108,7 +107,9 @@ struct AppAuthLicenseVerifier;
|
||||
|
||||
impl LicenseVerifier for AppAuthLicenseVerifier {
|
||||
fn validate(&self, raw_license: &str, _now: u64) -> LicenseResult<Token> {
|
||||
let token = parse_license(raw_license).map_err(|err| LicenseError::Invalid(err.to_string()))?;
|
||||
let public_key = license_public_key()?;
|
||||
let token =
|
||||
parse_license_with_public_key(raw_license, &public_key).map_err(|err| LicenseError::Invalid(err.to_string()))?;
|
||||
|
||||
#[cfg(feature = "license")]
|
||||
if token.expired <= _now {
|
||||
@@ -148,6 +149,30 @@ fn normalized_license(raw_license: Option<String>) -> Option<String> {
|
||||
raw_license.map(|raw| raw.trim().to_string()).filter(|raw| !raw.is_empty())
|
||||
}
|
||||
|
||||
fn license_public_key() -> LicenseResult<String> {
|
||||
let public_key = std::env::var(rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY)
|
||||
.map(|raw| raw.trim().to_string())
|
||||
.map_err(|_| {
|
||||
LicenseError::Invalid(format!(
|
||||
"{} must contain the RSA public key used to verify licenses",
|
||||
rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY
|
||||
))
|
||||
})?;
|
||||
|
||||
if public_key.is_empty() {
|
||||
return Err(LicenseError::Invalid(format!(
|
||||
"{} must contain the RSA public key used to verify licenses",
|
||||
rustfs_config::ENV_RUSTFS_LICENSE_PUBLIC_KEY
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
fn is_license_token_current(token: &Token, now: u64) -> bool {
|
||||
token.expired > now
|
||||
}
|
||||
|
||||
fn strict_build_missing_status() -> LicenseStatus {
|
||||
if cfg!(feature = "license") {
|
||||
LicenseStatus::Missing
|
||||
@@ -243,6 +268,18 @@ pub fn current_license() -> Option<Token> {
|
||||
get_license()
|
||||
}
|
||||
|
||||
/// Return whether the loaded license token is present and not expired.
|
||||
pub fn has_valid_license() -> bool {
|
||||
let Some(token) = get_license() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(now) = now_epoch_secs() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
is_license_token_current(&token, now)
|
||||
}
|
||||
|
||||
/// Observe the current license status for observability.
|
||||
pub fn license_status() -> String {
|
||||
license_state()
|
||||
@@ -283,3 +320,20 @@ pub fn ensure_license() -> LicenseResult<()> {
|
||||
pub fn license_check() -> Result<()> {
|
||||
ensure_license().map_err(LicenseError::into_io)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn license_token_current_requires_future_expiration() {
|
||||
let token = Token {
|
||||
name: "test_app".to_string(),
|
||||
expired: 100,
|
||||
};
|
||||
|
||||
assert!(is_license_token_current(&token, 99));
|
||||
assert!(!is_license_token_current(&token, 100));
|
||||
assert!(!is_license_token_current(&token, 101));
|
||||
}
|
||||
}
|
||||
|
||||
+46
-35
@@ -670,7 +670,7 @@ pub struct ConditionalCorsLayer {
|
||||
|
||||
impl ConditionalCorsLayer {
|
||||
pub fn new() -> Self {
|
||||
let cors_origins = get_env_opt_str("RUSTFS_CORS_ALLOWED_ORIGINS").filter(|s| !s.is_empty());
|
||||
let cors_origins = get_env_opt_str(rustfs_config::ENV_CORS_ALLOWED_ORIGINS).filter(|s| !s.is_empty());
|
||||
Self { cors_origins }
|
||||
}
|
||||
|
||||
@@ -687,36 +687,31 @@ impl ConditionalCorsLayer {
|
||||
}
|
||||
|
||||
fn apply_cors_headers(&self, request_headers: &HeaderMap, response_headers: &mut HeaderMap) {
|
||||
let origin = request_headers
|
||||
.get(cors::standard::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let allowed_origin = match (origin, &self.cors_origins) {
|
||||
(Some(orig), Some(config)) if config == "*" => Some(orig),
|
||||
(Some(orig), Some(config)) => {
|
||||
if config.split(',').map(|s| s.trim()).any(|x| x == orig.as_str()) {
|
||||
Some(orig)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(Some(orig), None) => Some(orig), // Default: allow all if not configured
|
||||
_ => None,
|
||||
let Some(origin) = request_headers.get(cors::standard::ORIGIN).and_then(|v| v.to_str().ok()) else {
|
||||
return;
|
||||
};
|
||||
let Some(config) = self
|
||||
.cors_origins
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|config| !config.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Track whether we're using a specific origin (not wildcard)
|
||||
let using_specific_origin = if let Some(origin) = &allowed_origin {
|
||||
if let Ok(header_value) = HeaderValue::from_str(origin) {
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, header_value);
|
||||
true // Using specific origin, credentials allowed
|
||||
} else {
|
||||
false
|
||||
}
|
||||
let (allow_origin, allow_credentials) = if config == "*" {
|
||||
(HeaderValue::from_static("*"), false)
|
||||
} else if config.split(',').map(str::trim).any(|allowed| allowed == origin) {
|
||||
let Ok(origin) = HeaderValue::from_str(origin) else {
|
||||
return;
|
||||
};
|
||||
(origin, true)
|
||||
} else {
|
||||
false
|
||||
return;
|
||||
};
|
||||
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
|
||||
|
||||
// Allow all methods by default (S3-compatible set)
|
||||
response_headers.insert(
|
||||
cors::response::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
@@ -732,9 +727,8 @@ impl ConditionalCorsLayer {
|
||||
HeaderValue::from_static("x-request-id, content-type, content-length, etag"),
|
||||
);
|
||||
|
||||
// Only set credentials when using a specific origin (not wildcard)
|
||||
// CORS spec: credentials cannot be used with wildcard origins
|
||||
if using_specific_origin {
|
||||
// Credentials are only safe for origins matched from an explicit allow-list.
|
||||
if allow_credentials {
|
||||
response_headers.insert(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS, HeaderValue::from_static("true"));
|
||||
}
|
||||
}
|
||||
@@ -1077,7 +1071,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_cors_layer_echoes_allowed_origin() {
|
||||
fn test_generic_cors_layer_omits_headers_without_configured_origins() {
|
||||
let cors = ConditionalCorsLayer { cors_origins: None };
|
||||
let mut req_headers = HeaderMap::new();
|
||||
req_headers.insert("origin", "https://example.com".parse().unwrap());
|
||||
@@ -1085,10 +1079,11 @@ mod tests {
|
||||
let mut resp_headers = HeaderMap::new();
|
||||
cors.apply_cors_headers(&req_headers, &mut resp_headers);
|
||||
|
||||
assert_eq!(
|
||||
resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://example.com"
|
||||
);
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_METHODS).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_HEADERS).is_none());
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_EXPOSE_HEADERS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1111,11 +1106,27 @@ mod tests {
|
||||
resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://allowed.com"
|
||||
);
|
||||
assert_eq!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_cors_layer_wildcard_does_not_allow_credentials() {
|
||||
let cors = ConditionalCorsLayer {
|
||||
cors_origins: Some("*".to_string()),
|
||||
};
|
||||
|
||||
let mut req_headers = HeaderMap::new();
|
||||
req_headers.insert("origin", "https://example.com".parse().unwrap());
|
||||
let mut resp_headers = HeaderMap::new();
|
||||
cors.apply_cors_headers(&req_headers, &mut resp_headers);
|
||||
|
||||
assert_eq!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*");
|
||||
assert!(resp_headers.get(cors::response::ACCESS_CONTROL_ALLOW_CREDENTIALS).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conditional_cors_layer_reads_env() {
|
||||
with_var("RUSTFS_CORS_ALLOWED_ORIGINS", Some("https://allowed.com"), || {
|
||||
with_var(rustfs_config::ENV_CORS_ALLOWED_ORIGINS, Some("https://allowed.com"), || {
|
||||
let cors = ConditionalCorsLayer::new();
|
||||
assert_eq!(cors.cors_origins.as_deref(), Some("https://allowed.com"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user