mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-31 01:09:23 +00:00
fix(rpc): authenticate internode put file bodies (#5868)
This commit is contained in:
@@ -27,7 +27,10 @@
|
||||
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q>
|
||||
|
||||
use crate::cluster::rpc::context_propagation::{inject_request_id_into_http_headers, inject_trace_context_into_http_headers};
|
||||
use crate::storage_api_contracts::internode::NS_SCANNER_PROTOCOL_VERSION;
|
||||
use crate::storage_api_contracts::internode::{
|
||||
NS_SCANNER_PROTOCOL_VERSION, PUT_FILE_AUTH_TRAILER_DIGEST_LEN, PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN,
|
||||
PUT_FILE_AUTH_TRAILER_MAGIC,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
@@ -71,6 +74,7 @@ const RPC_REPLAY_SCOPE_VERSION_V3: &str = "3";
|
||||
const RPC_RESPONSE_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-response-proof-v1\0";
|
||||
const RPC_REPLAY_SCOPE_DOMAIN: &[u8] = b"rustfs-rpc-replay-scope-v3\0";
|
||||
const RPC_BOOT_EPOCH_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-boot-epoch-proof-v1\0";
|
||||
const HTTP_PUT_FILE_AUTH_DOMAIN: &[u8] = b"rustfs-http-put-file-auth-v1\0";
|
||||
const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
const UNSIGNED_PAYLOAD_NONCE: &str = "unsigned";
|
||||
const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes
|
||||
@@ -475,6 +479,13 @@ fn signature_payload(url: &str, method: &Method, timestamp: i64) -> String {
|
||||
format!("{url}|{method}|{timestamp}")
|
||||
}
|
||||
|
||||
fn canonical_path_and_query(url: &str) -> std::io::Result<String> {
|
||||
let uri: Uri = url.parse().map_err(|_| std::io::Error::other("Invalid RPC URL"))?;
|
||||
uri.path_and_query()
|
||||
.map(ToString::to_string)
|
||||
.ok_or_else(|| std::io::Error::other("Invalid RPC URL"))
|
||||
}
|
||||
|
||||
fn redacted_rpc_path(url: &str) -> String {
|
||||
url.parse::<Uri>()
|
||||
.ok()
|
||||
@@ -502,6 +513,76 @@ fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, si
|
||||
mac.verify_slice(&signature).is_ok()
|
||||
}
|
||||
|
||||
fn update_put_file_auth_mac(
|
||||
mac: &mut HmacSha256,
|
||||
url: &str,
|
||||
method: &Method,
|
||||
nonce: Uuid,
|
||||
body_sha256: &str,
|
||||
) -> std::io::Result<()> {
|
||||
if !valid_content_sha256(body_sha256) || body_sha256 == UNSIGNED_PAYLOAD {
|
||||
return Err(std::io::Error::other("Invalid RPC content SHA-256"));
|
||||
}
|
||||
let path_and_query = canonical_path_and_query(url)?;
|
||||
mac.update(HTTP_PUT_FILE_AUTH_DOMAIN);
|
||||
for part in [
|
||||
path_and_query.as_bytes(),
|
||||
b"|",
|
||||
method.as_str().as_bytes(),
|
||||
b"|",
|
||||
nonce.as_bytes(),
|
||||
b"|",
|
||||
body_sha256.as_bytes(),
|
||||
] {
|
||||
mac.update(part);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put_file_auth_mac(url: &str, method: &Method, nonce: Uuid, body_sha256: &str) -> std::io::Result<[u8; 32]> {
|
||||
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(get_shared_secret()?.as_bytes())
|
||||
.map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_put_file_auth_mac(&mut mac, url, method, nonce, body_sha256)?;
|
||||
Ok(mac.finalize().into_bytes().into())
|
||||
}
|
||||
|
||||
fn verify_put_file_auth_mac(url: &str, method: &Method, nonce: Uuid, body_sha256: &str, signature: &[u8]) -> std::io::Result<()> {
|
||||
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(get_shared_secret()?.as_bytes())
|
||||
.map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_put_file_auth_mac(&mut mac, url, method, nonce, body_sha256)?;
|
||||
mac.verify_slice(signature)
|
||||
.map_err(|_| std::io::Error::other("Invalid put_file auth trailer"))
|
||||
}
|
||||
|
||||
pub fn build_put_file_auth_trailer(url: &str, method: &Method, nonce: Uuid, body_sha256: &str) -> std::io::Result<Vec<u8>> {
|
||||
let mac = put_file_auth_mac(url, method, nonce, body_sha256)?;
|
||||
let mut trailer = Vec::with_capacity(PUT_FILE_AUTH_TRAILER_LEN);
|
||||
trailer.extend_from_slice(PUT_FILE_AUTH_TRAILER_MAGIC);
|
||||
trailer.extend_from_slice(body_sha256.as_bytes());
|
||||
trailer.extend_from_slice(&mac);
|
||||
Ok(trailer)
|
||||
}
|
||||
|
||||
pub fn verify_put_file_auth_trailer(url: &str, method: &Method, nonce: Uuid, trailer: &[u8]) -> std::io::Result<String> {
|
||||
if trailer.len() != PUT_FILE_AUTH_TRAILER_LEN {
|
||||
return Err(std::io::Error::other("Invalid put_file auth trailer length"));
|
||||
}
|
||||
if &trailer[..PUT_FILE_AUTH_TRAILER_MAGIC.len()] != PUT_FILE_AUTH_TRAILER_MAGIC {
|
||||
return Err(std::io::Error::other("Invalid put_file auth trailer"));
|
||||
}
|
||||
let digest_start = PUT_FILE_AUTH_TRAILER_MAGIC.len();
|
||||
let digest_end = digest_start + PUT_FILE_AUTH_TRAILER_DIGEST_LEN;
|
||||
let body_sha256 = std::str::from_utf8(&trailer[digest_start..digest_end])
|
||||
.map_err(|_| std::io::Error::other("Invalid RPC content SHA-256"))?;
|
||||
if !valid_content_sha256(body_sha256) || body_sha256 == UNSIGNED_PAYLOAD {
|
||||
return Err(std::io::Error::other("Invalid RPC content SHA-256"));
|
||||
}
|
||||
let mac_start = digest_end;
|
||||
let mac_end = mac_start + PUT_FILE_AUTH_TRAILER_MAC_LEN;
|
||||
verify_put_file_auth_mac(url, method, nonce, body_sha256, &trailer[mac_start..mac_end])?;
|
||||
Ok(body_sha256.to_string())
|
||||
}
|
||||
|
||||
fn update_ns_scanner_capability_mac(mac: &mut HmacSha256, challenge: Uuid, server_epoch: Uuid) {
|
||||
mac.update(NS_SCANNER_CAPABILITY_AUTH_DOMAIN);
|
||||
mac.update(&NS_SCANNER_PROTOCOL_VERSION.to_be_bytes());
|
||||
@@ -806,7 +887,13 @@ fn tonic_rpc_metric_operation(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::io::Result<()> {
|
||||
fn check_and_record_nonce_with_scope(
|
||||
nonce: Uuid,
|
||||
signed_at: i64,
|
||||
rpc_path: &str,
|
||||
operation: &'static str,
|
||||
backend: &'static str,
|
||||
) -> std::io::Result<()> {
|
||||
let wall_time = OffsetDateTime::now_utc().unix_timestamp();
|
||||
let (result, metrics) = {
|
||||
let mut cache = LOCAL_RPC_NONCE_CACHE
|
||||
@@ -826,8 +913,8 @@ fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::i
|
||||
expires_at,
|
||||
capacity: *REPLAY_CACHE_CAPACITY,
|
||||
metric_scope: RpcReplayCacheMetricScope {
|
||||
operation: tonic_rpc_metric_operation(rpc_path),
|
||||
backend: INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
operation,
|
||||
backend,
|
||||
rpc_path,
|
||||
},
|
||||
})
|
||||
@@ -836,6 +923,37 @@ fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::i
|
||||
result
|
||||
}
|
||||
|
||||
fn check_and_record_tonic_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::io::Result<()> {
|
||||
check_and_record_nonce_with_scope(
|
||||
nonce,
|
||||
signed_at,
|
||||
rpc_path,
|
||||
tonic_rpc_metric_operation(rpc_path),
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn check_and_record_signed_rpc_nonce(
|
||||
headers: &HeaderMap,
|
||||
nonce: Uuid,
|
||||
rpc_path: &str,
|
||||
operation: &'static str,
|
||||
backend: &'static str,
|
||||
) -> std::io::Result<()> {
|
||||
if nonce.is_nil() {
|
||||
return Err(std::io::Error::other("Invalid RPC nonce"));
|
||||
}
|
||||
let timestamp_header = headers
|
||||
.get(TIMESTAMP_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| std::io::Error::other("Missing timestamp header"))?;
|
||||
let timestamp = timestamp_header
|
||||
.parse::<i64>()
|
||||
.map_err(|_| std::io::Error::other("Invalid timestamp format"))?;
|
||||
check_timestamp(timestamp)?;
|
||||
check_and_record_nonce_with_scope(nonce, timestamp, rpc_path, operation, backend)
|
||||
}
|
||||
|
||||
/// Build headers with authentication signature
|
||||
pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) -> std::io::Result<()> {
|
||||
let auth_headers = gen_signature_headers(url, method)?;
|
||||
@@ -1095,7 +1213,7 @@ fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &Hea
|
||||
if boot_epoch != tonic_rpc_boot_epoch() {
|
||||
return Err(std::io::Error::other("RPC boot epoch is stale"));
|
||||
}
|
||||
check_and_record_nonce(nonce, signed_at, path)
|
||||
check_and_record_tonic_nonce(nonce, signed_at, path)
|
||||
}
|
||||
|
||||
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
|
||||
@@ -1160,6 +1278,8 @@ pub fn tonic_rpc_auth_failure_reason(error: &std::io::Error) -> &'static str {
|
||||
"Invalid unsigned RPC nonce" => "invalid_unsigned_v2_nonce",
|
||||
"Missing RPC content SHA-256" => "missing_content_sha256",
|
||||
"Invalid RPC content SHA-256" => "invalid_content_sha256",
|
||||
"Invalid put_file auth trailer length" => "invalid_put_file_auth_trailer_length",
|
||||
"Invalid put_file auth trailer" => "invalid_put_file_auth_trailer",
|
||||
"Missing signature header" => "missing_v1_signature",
|
||||
"Invalid signature" => "invalid_v1_signature",
|
||||
"Invalid RPC HMAC key" => "invalid_hmac_key",
|
||||
@@ -1286,7 +1406,7 @@ fn verify_tonic_rpc_signature_with_strictness(
|
||||
return Err(std::io::Error::other("Invalid RPC v2 signature"));
|
||||
}
|
||||
if let Some(nonce) = parsed_nonce {
|
||||
check_and_record_nonce(nonce, timestamp, path)?;
|
||||
check_and_record_tonic_nonce(nonce, timestamp, path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2031,6 +2151,8 @@ mod tests {
|
||||
("Request timestamp expired", "timestamp_expired"),
|
||||
("Missing RPC content SHA-256", "missing_content_sha256"),
|
||||
("Invalid RPC content SHA-256", "invalid_content_sha256"),
|
||||
("Invalid put_file auth trailer length", "invalid_put_file_auth_trailer_length"),
|
||||
("Invalid put_file auth trailer", "invalid_put_file_auth_trailer"),
|
||||
] {
|
||||
assert_eq!(
|
||||
tonic_rpc_auth_failure_reason(&std::io::Error::other(message)),
|
||||
@@ -2178,6 +2300,37 @@ mod tests {
|
||||
assert_eq!(tampered.to_string(), "RPC content SHA-256 mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_file_auth_trailer_binds_url_nonce_and_body_digest() {
|
||||
ensure_test_rpc_secret();
|
||||
let url = concat!(
|
||||
"/rustfs/rpc/put_file_stream?disk=disk-a&volume=bucket&path=object%2Fpart.1",
|
||||
"&append=false&size=11&put_file_auth=digest-trailer-v1&put_file_nonce=11111111-2222-4333-8444-555555555555"
|
||||
);
|
||||
let nonce = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("nonce");
|
||||
let body_sha256 = hex_simd::encode_to_string(Sha256::digest(b"hello world"), hex_simd::AsciiCase::Lower);
|
||||
let trailer = build_put_file_auth_trailer(url, &Method::PUT, nonce, &body_sha256).expect("trailer should build");
|
||||
|
||||
assert_eq!(trailer.len(), PUT_FILE_AUTH_TRAILER_LEN);
|
||||
let verified = verify_put_file_auth_trailer(url, &Method::PUT, nonce, &trailer).expect("trailer should verify");
|
||||
assert_eq!(verified, body_sha256);
|
||||
|
||||
let different_url = url.replace("size=11", "size=12");
|
||||
let err = verify_put_file_auth_trailer(&different_url, &Method::PUT, nonce, &trailer)
|
||||
.expect_err("trailer must bind the signed URL");
|
||||
assert_eq!(err.to_string(), "Invalid put_file auth trailer");
|
||||
|
||||
let err =
|
||||
verify_put_file_auth_trailer(url, &Method::PUT, Uuid::new_v4(), &trailer).expect_err("trailer must bind the nonce");
|
||||
assert_eq!(err.to_string(), "Invalid put_file auth trailer");
|
||||
|
||||
let mut tampered = trailer;
|
||||
tampered[PUT_FILE_AUTH_TRAILER_MAGIC.len()] = b'0';
|
||||
let err =
|
||||
verify_put_file_auth_trailer(url, &Method::PUT, nonce, &tampered).expect_err("trailer must bind the digest bytes");
|
||||
assert_eq!(err.to_string(), "Invalid put_file auth trailer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_mutation_rpc_contract_requires_method_bound_v2_body_digest() {
|
||||
ensure_test_rpc_secret();
|
||||
|
||||
Reference in New Issue
Block a user