diff --git a/Cargo.lock b/Cargo.lock index 31071cd94..1fd8e72a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7700,6 +7700,7 @@ dependencies = [ "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0", "rand_core 0.10.1", + "sha2 0.11.0", "signature 3.0.0-rc.10", "spki 0.8.0", "zeroize", diff --git a/Dockerfile b/Dockerfile index 88fc2c13e..a8da4fecb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,8 +99,7 @@ RUN addgroup -g 10001 -S rustfs && \ chown -R rustfs:rustfs /data /logs && \ chmod 0750 /data /logs -ENV RUSTFS_CORS_ALLOWED_ORIGINS="*" \ - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \ +ENV RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \ RUSTFS_VOLUMES="/data" \ RUSTFS_OBS_LOGGER_LEVEL=warn \ RUSTFS_OBS_LOG_DIRECTORY=/logs \ diff --git a/Dockerfile.glibc b/Dockerfile.glibc index 344302b8e..4ca9e40e7 100644 --- a/Dockerfile.glibc +++ b/Dockerfile.glibc @@ -108,7 +108,6 @@ ENV RUSTFS_ADDRESS=":9000" \ RUSTFS_ACCESS_KEY="rustfsadmin" \ RUSTFS_SECRET_KEY="rustfsadmin" \ RUSTFS_CONSOLE_ENABLE="true" \ - RUSTFS_CORS_ALLOWED_ORIGINS="*" \ RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS="*" \ RUSTFS_VOLUMES="/data" \ RUSTFS_OBS_LOGGER_LEVEL=warn \ diff --git a/crates/appauth/Cargo.toml b/crates/appauth/Cargo.toml index ee6c44b9e..59779e339 100644 --- a/crates/appauth/Cargo.toml +++ b/crates/appauth/Cargo.toml @@ -26,7 +26,7 @@ categories = ["web-programming", "development-tools", "authentication"] [dependencies] base64-simd = { workspace = true } -rsa = { workspace = true } +rsa = { workspace = true, features = ["sha2"] } serde.workspace = true serde_json.workspace = true rand.workspace = true diff --git a/crates/appauth/src/token.rs b/crates/appauth/src/token.rs index 377bef1de..22952a7d2 100644 --- a/crates/appauth/src/token.rs +++ b/crates/appauth/src/token.rs @@ -15,9 +15,13 @@ use rsa::{ Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey, pkcs8::{DecodePrivateKey, DecodePublicKey}, + pss::{BlindedSigningKey, Signature, VerifyingKey}, + sha2::Sha256, + signature::{RandomizedSigner, Verifier}, + traits::PublicKeyParts, }; use serde::{Deserialize, Serialize}; -use std::io::{Error, Result}; +use std::io::{Error, ErrorKind, Result}; #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct Token { @@ -25,10 +29,11 @@ pub struct Token { pub expired: u64, // Expiry time (UNIX timestamp) } -/// Public key generation Token -/// [token] Token object -/// [key] Public key string -/// Returns the encrypted string processed by base64 +/// Legacy public-key encryption Token encoder. +/// +/// Use `sign_license_token` for license issuance so verifiers only need a +/// public key. +#[deprecated(note = "use sign_license_token for signed license issuance")] pub fn gencode(token: &Token, key: &str) -> Result { let data = serde_json::to_vec(token)?; let mut rng = rand::rng(); @@ -37,35 +42,62 @@ pub fn gencode(token: &Token, key: &str) -> Result { Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&encrypted_data)) } -/// Private key resolution Token -/// [token] Encrypted string processed by base64 -/// [key] Private key string -/// Return to the Token object +/// Legacy private-key Token decoder. +/// +/// Use `parse_signed_license_token` or `parse_license_with_public_key` for +/// license verification so runtime services never need private key material. +#[deprecated(note = "use parse_signed_license_token or parse_license_with_public_key for signed license verification")] pub fn parse(token: &str, key: &str) -> Result { let encrypted_data = base64_simd::URL_SAFE_NO_PAD .decode_to_vec(token.as_bytes()) .map_err(Error::other)?; let private_key = RsaPrivateKey::from_pkcs8_pem(key).map_err(Error::other)?; let decrypted_data = private_key.decrypt(Pkcs1v15Encrypt, &encrypted_data).map_err(Error::other)?; - let res: Token = serde_json::from_slice(&decrypted_data)?; - Ok(res) + serde_json::from_slice(&decrypted_data).map_err(Error::other) } -pub fn parse_license(license: &str) -> Result { - parse(license, TEST_PRIVATE_KEY) - // match parse(license, TEST_PRIVATE_KEY) { - // Ok(token) => { - // if token.expired > SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() { - // Ok(token) - // } else { - // Err("Token expired".into()) - // } - // } - // Err(e) => Err(e), - // } +/// Signs a license token with an RSA private key. +/// +/// The returned token is base64url(signature || payload), where the signature is +/// RSASSA-PSS over the JSON payload using SHA-256. +pub fn sign_license_token(token: &Token, private_key_pem: &str) -> Result { + let payload = serde_json::to_vec(token)?; + let mut rng = rand::rng(); + let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(Error::other)?; + let signing_key = BlindedSigningKey::::new(private_key); + let signature: Signature = signing_key.try_sign_with_rng(&mut rng, &payload).map_err(Error::other)?; + let signature: Box<[u8]> = signature.into(); + + let mut signed_payload = Vec::with_capacity(signature.as_ref().len() + payload.len()); + signed_payload.extend_from_slice(signature.as_ref()); + signed_payload.extend_from_slice(&payload); + + Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&signed_payload)) } -static TEST_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCj86SrJIuxSxR6\nBJ/dlJEUIj6NeBRnhLQlCDdovuz61+7kJXVcxaR66w4m8W7SLEUP+IlPtnn6vmiG\n7XMhGNHIr7r1JsEVVLhZmL3tKI66DEZl786ZhG81BWqUlmcooIPS8UEPZNqJXLuz\nVGhxNyVGbj/tV7QC2pSISnKaixc+nrhxvo7w56p5qrm9tik0PjTgfZsUePkoBsSN\npoRkAauS14MAzK6HGB75CzG3dZqXUNWSWVocoWtQbZUwFGXyzU01ammsHQDvc2xu\nK1RQpd1qYH5bOWZ0N0aPFwT0r59HztFXg9sbjsnuhO1A7OiUOkc6iGVuJ0wm/9nA\nwZIBqzgjAgMBAAECggEAPMpeSEbotPhNw2BrllE76ec4omPfzPJbiU+em+wPGoNu\nRJHPDnMKJbl6Kd5jZPKdOOrCnxfd6qcnQsBQa/kz7+GYxMV12l7ra+1Cnujm4v0i\nLTHZvPpp8ZLsjeOmpF3AAzsJEJgon74OqtOlVjVIUPEYKvzV9ijt4gsYq0zfdYv0\nhrTMzyrGM4/UvKLsFIBROAfCeWfA7sXLGH8JhrRAyDrtCPzGtyyAmzoHKHtHafcB\nuyPFw/IP8otAgpDk5iiQPNkH0WwzAQIm12oHuNUa66NwUK4WEjXTnDg8KeWLHHNv\nIfN8vdbZchMUpMIvvkr7is315d8f2cHCB5gEO+GWAQKBgQDR/0xNll+FYaiUKCPZ\nvkOCAd3l5mRhsqnjPQ/6Ul1lAyYWpoJSFMrGGn/WKTa/FVFJRTGbBjwP+Mx10bfb\ngUg2GILDTISUh54fp4zngvTi9w4MWGKXrb7I1jPkM3vbJfC/v2fraQ/r7qHPpO2L\nf6ZbGxasIlSvr37KeGoelwcAQQKBgQDH3hmOTS2Hl6D4EXdq5meHKrfeoicGN7m8\noQK7u8iwn1R9zK5nh6IXxBhKYNXNwdCQtBZVRvFjjZ56SZJb7lKqa1BcTsgJfZCy\nnI3Uu4UykrECAH8AVCVqBXUDJmeA2yE+gDAtYEjvhSDHpUfWxoGHr0B/Oqk2Lxc/\npRy1qV5fYwKBgBWSL/hYVf+RhIuTg/s9/BlCr9SJ0g3nGGRrRVTlWQqjRCpXeFOO\nJzYqSq9pFGKUggEQxoOyJEFPwVDo9gXqRcyov+Xn2kaXl7qQr3yoixc1YZALFDWY\nd1ySBEqQr0xXnV9U/gvEgwotPRnjSzNlLWV2ZuHPtPtG/7M0o1H5GZMBAoGAKr3N\nW0gX53o+my4pCnxRQW+aOIsWq1a5aqRIEFudFGBOUkS2Oz+fI1P1GdrRfhnnfzpz\n2DK+plp/vIkFOpGhrf4bBlJ2psjqa7fdANRFLMaAAfyXLDvScHTQTCcnVUAHQPVq\n2BlSH56pnugyj7SNuLV6pnql+wdhAmRN2m9o1h8CgYAbX2juSr4ioXwnYjOUdrIY\n4+ERvHcXdjoJmmPcAm4y5NbSqLXyU0FQmplNMt2A5LlniWVJ9KNdjAQUt60FZw/+\nr76LdxXaHNZghyx0BOs7mtq5unSQXamZ8KixasfhE9uz3ij1jXjG6hafWkS8/68I\nuWbaZqgvy7a9oPHYlKH7Jg==\n-----END PRIVATE KEY-----\n"; +/// Verifies and parses a signed license token with an RSA public key. +pub fn parse_signed_license_token(token: &str, public_key_pem: &str) -> Result { + let signed_payload = base64_simd::URL_SAFE_NO_PAD + .decode_to_vec(token.as_bytes()) + .map_err(Error::other)?; + let public_key = RsaPublicKey::from_public_key_pem(public_key_pem).map_err(Error::other)?; + let signature_len = public_key.size(); + + if signed_payload.len() <= signature_len { + return Err(Error::new(ErrorKind::InvalidData, "license token is missing signed payload")); + } + + let (signature, payload) = signed_payload.split_at(signature_len); + let signature = Signature::try_from(signature).map_err(Error::other)?; + let verifying_key = VerifyingKey::::new(public_key); + verifying_key.verify(payload, &signature).map_err(Error::other)?; + + serde_json::from_slice(payload).map_err(Error::other) +} + +pub fn parse_license_with_public_key(license: &str, public_key: &str) -> Result { + parse_signed_license_token(license, public_key) +} #[cfg(test)] mod tests { @@ -77,7 +109,7 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; #[test] - fn test_gencode_and_parse() { + fn test_sign_license_token_and_parse_signed_license_token() { let mut rng = rand::rng(); let bits = 2048; let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key"); @@ -91,8 +123,31 @@ mod tests { expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now }; - let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token"); + let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token"); + let decoded = parse_signed_license_token(&encoded, &public_key_pem).expect("Failed to decode token"); + + assert_eq!(token.name, decoded.name); + assert_eq!(token.expired, decoded.expired); + } + + #[test] + #[allow(deprecated)] + fn test_legacy_gencode_and_parse_roundtrip() { + let mut rng = rand::rng(); + let bits = 2048; + let private_key = RsaPrivateKey::new(&mut rng, bits).expect("Failed to generate private key"); + let public_key = RsaPublicKey::from(&private_key); + + let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap(); + let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap(); + + let token = Token { + name: "test_app".to_string(), + expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, + }; + + let encoded = gencode(&token, &public_key_pem).expect("Failed to encode token"); let decoded = parse(&encoded, &private_key_pem).expect("Failed to decode token"); assert_eq!(token.name, decoded.name); @@ -100,28 +155,60 @@ mod tests { } #[test] - fn test_parse_invalid_token() { + fn test_parse_signed_license_token_rejects_tampered_payload() { let mut rng = rand::rng(); - let private_key_pem = RsaPrivateKey::new(&mut rng, 2048) - .expect("Failed to generate private key") - .to_pkcs8_pem(LineEnding::LF) - .unwrap(); + let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key"); + let public_key = RsaPublicKey::from(&private_key); + let private_key_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap(); + let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap(); + let token = Token { + name: "test_app".to_string(), + expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, + }; - let invalid_token = "invalid_base64_token"; - let result = parse(invalid_token, &private_key_pem); + let encoded = sign_license_token(&token, &private_key_pem).expect("Failed to encode token"); + let mut signed_payload = base64_simd::URL_SAFE_NO_PAD + .decode_to_vec(encoded.as_bytes()) + .expect("Failed to decode signed payload"); + let last_byte = signed_payload.last_mut().expect("Signed payload should not be empty"); + *last_byte ^= 0x01; + let tampered = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&signed_payload); + + let result = parse_signed_license_token(&tampered, &public_key_pem); assert!(result.is_err()); } #[test] - fn test_gencode_with_invalid_key() { + fn test_source_does_not_embed_private_key() { + let source = include_str!("token.rs"); + let forbidden = ["BEGIN", "PRIVATE KEY"].join(" "); + + assert!(!source.contains(&forbidden)); + } + + #[test] + fn test_parse_signed_license_token_rejects_invalid_token() { + let mut rng = rand::rng(); + let private_key = RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate private key"); + let public_key = RsaPublicKey::from(&private_key); + let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).unwrap(); + + let invalid_token = "invalid_base64_token"; + let result = parse_signed_license_token(invalid_token, &public_key_pem); + + assert!(result.is_err()); + } + + #[test] + fn test_sign_license_token_with_invalid_signing_key() { let token = Token { name: "test_app".to_string(), expired: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600, // 1 hour from now }; - let invalid_key = "invalid_public_key"; - let result = gencode(&token, invalid_key); + let invalid_key = "invalid_private_key"; + let result = sign_license_token(&token, invalid_key); assert!(result.is_err()); } diff --git a/crates/config/README.md b/crates/config/README.md index 193c90845..560ddbd81 100644 --- a/crates/config/README.md +++ b/crates/config/README.md @@ -41,6 +41,7 @@ Examples: - `RUSTFS_ADDRESS` - `RUSTFS_VOLUMES` - `RUSTFS_LICENSE` +- `RUSTFS_LICENSE_PUBLIC_KEY` Current guidance: - Prefer module-specific names only when they are not top-level product configuration. @@ -51,6 +52,16 @@ Current guidance: - `RUSTFS_ENABLE_HEAL` -> `RUSTFS_HEAL_ENABLED` - `RUSTFS_DATA_SCANNER_START_DELAY_SECS` -> `RUSTFS_SCANNER_START_DELAY_SECS` +## License environment variables + +- `RUSTFS_LICENSE` contains the signed license token. +- `RUSTFS_LICENSE_PUBLIC_KEY` contains the RSA public key used to verify signed license tokens. + +## CORS environment variables + +- `RUSTFS_CORS_ALLOWED_ORIGINS` defaults to empty, so the S3 endpoint emits no generic CORS headers unless configured. Set `*` for wildcard origins without credentials, or a comma-separated allow-list for credentialed explicit origins. +- `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` defaults to `*` for the console service. + ## Scanner environment aliases - `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`) diff --git a/crates/config/src/constants/app.rs b/crates/config/src/constants/app.rs index 8a8a2a4b7..cc841e296 100644 --- a/crates/config/src/constants/app.rs +++ b/crates/config/src/constants/app.rs @@ -235,6 +235,9 @@ pub const ENV_RUSTFS_REGION: &str = "RUSTFS_REGION"; /// Environment variable for server license. pub const ENV_RUSTFS_LICENSE: &str = "RUSTFS_LICENSE"; +/// Environment variable for the RSA public key used to verify server licenses. +pub const ENV_RUSTFS_LICENSE_PUBLIC_KEY: &str = "RUSTFS_LICENSE_PUBLIC_KEY"; + /// Default log filename for rustfs /// This is the default log filename for rustfs. /// It is used to store the logs of the application. @@ -348,6 +351,7 @@ mod tests { fn test_environment_constants() { // Test environment related constants assert_eq!(ENVIRONMENT, "production"); + assert_eq!(ENV_RUSTFS_LICENSE_PUBLIC_KEY, "RUSTFS_LICENSE_PUBLIC_KEY"); assert!( ["development", "staging", "production", "test"].contains(&ENVIRONMENT), "Environment should be a standard environment name" diff --git a/crates/config/src/constants/console.rs b/crates/config/src/constants/console.rs index beaa35edd..c484372d3 100644 --- a/crates/config/src/constants/console.rs +++ b/crates/config/src/constants/console.rs @@ -17,9 +17,8 @@ pub const ENV_CORS_ALLOWED_ORIGINS: &str = "RUSTFS_CORS_ALLOWED_ORIGINS"; /// Default CORS allowed origins for the endpoint service -/// Comes from the console service default -/// See DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS -pub const DEFAULT_CORS_ALLOWED_ORIGINS: &str = DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS; +/// Empty means the S3 endpoint emits no generic CORS headers unless configured. +pub const DEFAULT_CORS_ALLOWED_ORIGINS: &str = ""; /// CORS allowed origins for the console service /// Comma-separated list of origins or "*" for all origins @@ -89,3 +88,20 @@ pub const ENV_UPDATE_CHECK: &str = "RUSTFS_CHECK_UPDATE"; /// Default value for update toggle pub const DEFAULT_UPDATE_CHECK: bool = true; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_cors_default_is_restrictive() { + assert_eq!(ENV_CORS_ALLOWED_ORIGINS, "RUSTFS_CORS_ALLOWED_ORIGINS"); + assert_eq!(DEFAULT_CORS_ALLOWED_ORIGINS, ""); + } + + #[test] + fn console_cors_default_remains_wildcard() { + assert_eq!(ENV_CONSOLE_CORS_ALLOWED_ORIGINS, "RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS"); + assert_eq!(DEFAULT_CONSOLE_CORS_ALLOWED_ORIGINS, "*"); + } +} diff --git a/docker-compose-simple.yml b/docker-compose-simple.yml index 645320e68..a39283fba 100644 --- a/docker-compose-simple.yml +++ b/docker-compose-simple.yml @@ -29,7 +29,6 @@ services: - RUSTFS_ADDRESS=0.0.0.0:9000 - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 - RUSTFS_CONSOLE_ENABLE=true - - RUSTFS_CORS_ALLOWED_ORIGINS=* - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=* - RUSTFS_ACCESS_KEY=rustfsadmin # CHANGEME - RUSTFS_SECRET_KEY=rustfsadmin # CHANGEME diff --git a/docker-compose.decommission.yml b/docker-compose.decommission.yml index 8fcd86ddc..f1c34cef1 100644 --- a/docker-compose.decommission.yml +++ b/docker-compose.decommission.yml @@ -19,7 +19,6 @@ services: RUSTFS_ADDRESS: "0.0.0.0:9000" RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001" RUSTFS_CONSOLE_ENABLE: "true" - RUSTFS_CORS_ALLOWED_ORIGINS: "*" RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*" RUSTFS_ACCESS_KEY: "rustfsadmin" RUSTFS_SECRET_KEY: "rustfsadmin" diff --git a/docker-compose.yml b/docker-compose.yml index 391840a6b..410d62849 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,6 @@ services: - RUSTFS_ADDRESS=0.0.0.0:9000 - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 - RUSTFS_CONSOLE_ENABLE=true - - RUSTFS_CORS_ALLOWED_ORIGINS=* - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=* - RUSTFS_ACCESS_KEY=rustfsadmin - RUSTFS_SECRET_KEY=rustfsadmin @@ -90,7 +89,6 @@ services: - RUSTFS_ADDRESS=0.0.0.0:9000 - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 - RUSTFS_CONSOLE_ENABLE=true - - RUSTFS_CORS_ALLOWED_ORIGINS=* - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=* - RUSTFS_ACCESS_KEY=devadmin - RUSTFS_SECRET_KEY=devadmin diff --git a/rustfs/src/admin/console.rs b/rustfs/src/admin/console.rs index 2b37e723f..e0dc95cab 100644 --- a/rustfs/src/admin/console.rs +++ b/rustfs/src/admin/console.rs @@ -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()); + } } diff --git a/rustfs/src/license.rs b/rustfs/src/license.rs index d74a507f7..9652dc434 100644 --- a/rustfs/src/license.rs +++ b/rustfs/src/license.rs @@ -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 { - 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) -> Option { raw_license.map(|raw| raw.trim().to_string()).filter(|raw| !raw.is_empty()) } +fn license_public_key() -> LicenseResult { + 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 { 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)); + } +} diff --git a/rustfs/src/server/layer.rs b/rustfs/src/server/layer.rs index f1fa4a0b2..fb55147f4 100644 --- a/rustfs/src/server/layer.rs +++ b/rustfs/src/server/layer.rs @@ -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")); });