diff --git a/crates/ecstore/src/api/mod.rs b/crates/ecstore/src/api/mod.rs index 791c0c56d..62ed02ffa 100644 --- a/crates/ecstore/src/api/mod.rs +++ b/crates/ecstore/src/api/mod.rs @@ -461,8 +461,8 @@ pub mod rpc { tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest, - verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, - verify_tonic_rpc_signature_with_bootstrap, + verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof, + verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap, }; } diff --git a/crates/ecstore/src/cluster/rpc/http_auth.rs b/crates/ecstore/src/cluster/rpc/http_auth.rs index 157da2c57..32ab79a68 100644 --- a/crates/ecstore/src/cluster/rpc/http_auth.rs +++ b/crates/ecstore/src/cluster/rpc/http_auth.rs @@ -1307,6 +1307,32 @@ pub fn verify_tonic_mutation_body_digest(request: &tonic::Request, canonic verify_tonic_mutation_body_digest_with_strictness(request, canonical_body, internode_rpc_body_digest_strict()) } +/// Verify a non-disk mutation without accepting a newly-generated unsigned v2 body. +/// +/// The disk mutation lane has a rolling-upgrade exception for `UNSIGNED-PAYLOAD` +/// while peer replay-cache capability is being discovered. Historical v2 peers +/// used the fixed `unsigned` nonce before body-digest rollout; preserve that +/// exact marker for mixed-version compatibility, but reject unsigned v2 +/// requests that omit it or present a different nonce. +pub fn verify_tonic_mutation_body_digest_reject_unsigned( + request: &tonic::Request, + canonical_body: &[u8], +) -> std::io::Result<()> { + let version = request + .metadata() + .get(RPC_AUTH_VERSION_HEADER) + .and_then(|value| value.to_str().ok()); + let digest = request + .metadata() + .get(RPC_CONTENT_SHA256_HEADER) + .and_then(|value| value.to_str().ok()); + let nonce = request.metadata().get(RPC_NONCE_HEADER).and_then(|value| value.to_str().ok()); + if version == Some(RPC_AUTH_VERSION_V2) && digest == Some(UNSIGNED_PAYLOAD) && nonce != Some("unsigned") { + return Err(std::io::Error::other("RPC mutation requires a body-bound v2 signature")); + } + verify_tonic_mutation_body_digest(request, canonical_body) +} + /// [`verify_tonic_mutation_body_digest`] with the strict gate injected as a parameter, so both /// rollout postures are unit-testable without racing on process-global environment variables. fn verify_tonic_mutation_body_digest_with_strictness( diff --git a/crates/ecstore/src/cluster/rpc/mod.rs b/crates/ecstore/src/cluster/rpc/mod.rs index e8c3d5434..36886b36e 100644 --- a/crates/ecstore/src/cluster/rpc/mod.rs +++ b/crates/ecstore/src/cluster/rpc/mod.rs @@ -39,8 +39,8 @@ pub use http_auth::{ sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest, - verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, - verify_tonic_rpc_signature_with_bootstrap, + verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof, + verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap, }; #[cfg(test)] pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport; diff --git a/rustfs/src/storage/rpc/node_service.rs b/rustfs/src/storage/rpc/node_service.rs index c6b7a740f..83ea962e7 100644 --- a/rustfs/src/storage/rpc/node_service.rs +++ b/rustfs/src/storage/rpc/node_service.rs @@ -31,6 +31,7 @@ use crate::storage::storage_api::rpc_consumer::node_service::{ use crate::storage::storage_api::runtime_sources_consumer::{EndpointServerPools, runtime_sources}; use crate::storage::storage_api::{ sign_tonic_rpc_response_proof, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, + verify_tonic_mutation_body_digest_reject_unsigned, }; use bytes::Bytes; use futures::Stream; @@ -123,6 +124,15 @@ fn verify_node_mutation_body(request: &Request, ope .map_err(|err| Status::permission_denied(format!("{operation} authentication failed: {err}"))) } +fn verify_node_signal_body(request: &Request, operation: &'static str) -> Result<(), Status> { + let canonical_body = request + .get_ref() + .canonical_body() + .map_err(|_| Status::invalid_argument(format!("{operation} request length cannot be represented")))?; + verify_tonic_mutation_body_digest_reject_unsigned(request, &canonical_body) + .map_err(|err| Status::permission_denied(format!("{operation} authentication failed: {err}"))) +} + fn start_decommission_failure_response(err: Error) -> StartDecommissionResponse { match err { Error::InvalidArgument(_, _, reason) => StartDecommissionResponse { @@ -1839,7 +1849,7 @@ impl Node for NodeService { } async fn signal_service(&self, request: Request) -> Result, Status> { - verify_node_mutation_body(&request, "signal service")?; + verify_node_signal_body(&request, "signal service")?; let request = request.into_inner(); let vars = match request.vars { Some(vars) => vars.value, @@ -4744,6 +4754,34 @@ mod tests { assert!(refresh_response.error_info.is_some()); } + #[tokio::test] + async fn lock_rolling_unsigned_v2_remains_compatible_for_unknown_peer() { + let service = create_test_node_service(); + let unsigned_request = || { + let mut request = Request::new(GenerallyLockRequest { + args: "invalid json".to_string(), + }); + request + .metadata_mut() + .insert("x-rustfs-rpc-auth-version", "2".parse().expect("valid metadata value")); + request + .metadata_mut() + .insert("x-rustfs-content-sha256", "UNSIGNED-PAYLOAD".parse().expect("valid metadata value")); + request + }; + + let lock = service + .lock(unsigned_request()) + .await + .expect("unsigned lock must pass the rolling body gate"); + assert!(!lock.into_inner().success, "invalid test lock args should fail in the lock handler"); + let unlock = service + .un_lock(unsigned_request()) + .await + .expect("unsigned unlock must pass the rolling body gate"); + assert!(!unlock.into_inner().success, "invalid test unlock args should fail in the unlock handler"); + } + /// Premise guard for the no-object-layer RPC tests (backlog#1830): they /// assert the error surface returned while the global object layer is /// absent. Under nextest — the authoritative runner — every test owns its @@ -5561,6 +5599,54 @@ mod tests { assert_eq!(response.error_info.as_deref(), Some("unsupported service signal: 99")); } + #[tokio::test] + async fn signal_service_rejects_explicitly_unsigned_v2_body() { + let service = create_test_node_service(); + let request = SignalServiceRequest { + vars: Some(Mss { + value: HashMap::from([(PEER_RESTSIGNAL.to_string(), "99".to_string())]), + }), + }; + let mut request = Request::new(request); + request + .metadata_mut() + .insert("x-rustfs-rpc-auth-version", "2".parse().expect("valid metadata value")); + request + .metadata_mut() + .insert("x-rustfs-content-sha256", "UNSIGNED-PAYLOAD".parse().expect("valid metadata value")); + + let error = service + .signal_service(request) + .await + .expect_err("an explicitly unsigned v2 signal must fail before handler logic"); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + + #[tokio::test] + async fn signal_service_accepts_historical_unsigned_v2_marker_during_rollout() { + let service = create_test_node_service(); + let mut request = Request::new(SignalServiceRequest { + vars: Some(Mss { + value: HashMap::from([(PEER_RESTSIGNAL.to_string(), "99".to_string())]), + }), + }); + request + .metadata_mut() + .insert("x-rustfs-rpc-auth-version", "2".parse().expect("valid metadata value")); + request + .metadata_mut() + .insert("x-rustfs-content-sha256", "UNSIGNED-PAYLOAD".parse().expect("valid metadata value")); + request + .metadata_mut() + .insert("x-rustfs-rpc-nonce", "unsigned".parse().expect("valid metadata value")); + + let response = service + .signal_service(request) + .await + .expect("historical unsigned v2 marker must remain compatible during rollout"); + assert!(!response.into_inner().success, "invalid signal fixture should reach handler validation"); + } + #[tokio::test] async fn every_non_disk_mutation_rejects_a_mismatched_body_digest() { let service = create_test_node_service(); diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index 81c0040ca..14587b344 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -539,7 +539,8 @@ pub(crate) mod ecstore_rpc { sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_put_file_auth_trailer, verify_rpc_signature, verify_tonic_canonical_body_digest, - verify_tonic_mutation_body_digest, verify_tonic_rpc_signature_with_bootstrap, + verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, + verify_tonic_rpc_signature_with_bootstrap, }; #[cfg(test)] pub(crate) use rustfs_ecstore::api::rpc::{ @@ -1903,6 +1904,13 @@ pub(crate) fn verify_tonic_mutation_body_digest(request: &tonic::Request, ecstore_rpc::verify_tonic_mutation_body_digest(request, canonical_body) } +pub(crate) fn verify_tonic_mutation_body_digest_reject_unsigned( + request: &tonic::Request, + canonical_body: &[u8], +) -> std::io::Result<()> { + ecstore_rpc::verify_tonic_mutation_body_digest_reject_unsigned(request, canonical_body) +} + #[cfg(test)] pub(crate) fn set_tonic_canonical_body_digest(request: &mut tonic::Request, canonical_body: &[u8]) -> std::io::Result<()> { ecstore_rpc::set_tonic_canonical_body_digest(request, canonical_body)