mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-29 00:17:11 +00:00
daeaf40e2c
* test(ecstore): decouple server config snapshot test from global defaults The final assertion of server_config_snapshot_serializes_read_modify_write_transactions compared the second snapshot against a fresh Config::new(). Config::new() reads the process-global DEFAULT_KVS OnceLock, which a sibling test in the same process can register mid-run (crate::config::init()), so the in-process run 'cargo test -p rustfs-ecstore --lib config::' failed while nextest's process-per-test isolation hid the coupling. Assert on the snapshot's raw bytes against the baseline blob instead, which is deterministic and matches the invariant under test: the second transaction observes the store unchanged by the first. * test: deflake presigned tamper helper and relocated-pool resume staging tamper_signature only remapped '0' and 'a', so a signature containing neither (about 1 in 5000) left the URI unchanged and tripped the helper's own guard assert in CI. Complement every hex digit (15 - v) instead: the map has no fixed point, so the tamper always changes the value while keeping length and hex shape. execute_get_object_resumes_from_relocated_pool_without_splicing_body staged the relocation by reading xl.meta from every source-pool disk, but a write-quorum commit legitimately leaves a lagging minority disk without the object directory (#6701) — the test already tolerates that gap when normalizing the upload pool, and CI suite IO load hit the same gap in the staging loop. Skip sourceless disks, carry the staged metadata path explicitly, and assert a write-quorum majority was staged.
359 lines
14 KiB
Rust
359 lines
14 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Negative presigned-URL (query-string SigV4) regression suite (backlog#1151
|
|
//! sec-2).
|
|
//!
|
|
//! Presigned URLs are the query-string SigV4 surface: the signature and its
|
|
//! scope/expiry travel as `X-Amz-*` query parameters rather than in an
|
|
//! `Authorization` header. Until now this repository only exercised presigned
|
|
//! URLs on the *happy* path (e.g. `head_object_consistency_test`), so nothing
|
|
//! pinned OUR end-to-end wiring of expiry enforcement or query-signature
|
|
//! verification — a future dependency swap or misconfiguration could silently
|
|
//! start honouring expired or forged presigned URLs. These tests send REJECTED
|
|
//! presigned requests against a live server and assert the HTTP status plus the
|
|
//! S3 error `<Code>` in the response XML, guarding the rejection contract
|
|
//! regardless of who performs the underlying verification.
|
|
//!
|
|
//! This is the query-string sibling of `negative_sigv4_test` (sec-1, header
|
|
//! SigV4); the two cover distinct attacker-controlled auth surfaces and share
|
|
//! no test cases.
|
|
//!
|
|
//! Expiry is controlled WITHOUT real waiting: the AWS SDK presigner accepts an
|
|
//! explicit `start_time`, so an already-expired URL is produced by signing with
|
|
//! a timestamp far enough in the past that `start_time + X-Amz-Expires` is
|
|
//! already behind the server clock. Forged variants are produced by presigning
|
|
//! a valid URL and then mutating the query (`X-Amz-Signature`) or the signed
|
|
//! target (object key) after the fact, and by presigning with the wrong secret.
|
|
|
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
|
use aws_sdk_s3::config::{Credentials, Region};
|
|
use aws_sdk_s3::presigning::{PresignedRequest, PresigningConfig};
|
|
use aws_sdk_s3::primitives::ByteStream;
|
|
use aws_sdk_s3::{Client, Config};
|
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
|
use std::time::{Duration, SystemTime};
|
|
use tracing::info;
|
|
|
|
const REGION: &str = "us-east-1";
|
|
const BUCKET: &str = "presigned-negative-bucket";
|
|
/// Object that `setup` stores; positive-control GETs read it back.
|
|
const CANONICAL_KEY: &str = "canonical-object.txt";
|
|
const CANONICAL_BODY: &[u8] = b"presigned-negative-canonical-body";
|
|
|
|
/// Build an S3 client bound to this environment but with a caller-chosen secret
|
|
/// key (mirrors `common::build_test_s3_config`, which is private). Used to
|
|
/// presign with the WRONG secret while keeping the real access key id.
|
|
fn s3_client_with_secret(env: &RustFSTestEnvironment, secret: &str) -> Client {
|
|
let credentials = Credentials::new(&env.access_key, secret, None, None, "e2e-presigned-negative");
|
|
let mut config = Config::builder()
|
|
.credentials_provider(credentials)
|
|
.region(Region::new(REGION))
|
|
.endpoint_url(&env.url)
|
|
.force_path_style(true)
|
|
.behavior_version_latest();
|
|
if env.url.starts_with("http://") {
|
|
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
|
|
}
|
|
Client::from_conf(config.build())
|
|
}
|
|
|
|
/// A presigning config that is ALREADY expired the moment it is produced:
|
|
/// signed as of one hour ago with a 60s validity window, so the server sees a
|
|
/// request whose `X-Amz-Date + X-Amz-Expires` is ~59 minutes in the past. No
|
|
/// real waiting, no flakiness.
|
|
fn expired_config() -> PresigningConfig {
|
|
PresigningConfig::builder()
|
|
.start_time(SystemTime::now() - Duration::from_secs(3600))
|
|
.expires_in(Duration::from_secs(60))
|
|
.build()
|
|
.expect("valid presigning config")
|
|
}
|
|
|
|
/// A generous, valid presigning window for positive controls / pre-tamper URLs.
|
|
fn valid_config() -> PresigningConfig {
|
|
PresigningConfig::expires_in(Duration::from_secs(300)).expect("valid presigning config")
|
|
}
|
|
|
|
/// Flip bytes inside the `X-Amz-Signature=` query value without changing its
|
|
/// length, producing a structurally valid but incorrect signature. Every hex
|
|
/// digit is replaced by its complement (15 - v), which has no fixed point, so
|
|
/// the tamper changes the value no matter which digits the signature contains.
|
|
fn tamper_signature(uri: &str) -> String {
|
|
let marker = "X-Amz-Signature=";
|
|
let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len();
|
|
let (head, rest) = uri.split_at(idx);
|
|
let end = rest.find('&').unwrap_or(rest.len());
|
|
let (sig, tail) = rest.split_at(end);
|
|
let tampered: String = sig
|
|
.chars()
|
|
.map(|c| {
|
|
let v = c.to_digit(16).expect("X-Amz-Signature value must be hex");
|
|
char::from_digit(15 - v, 16).expect("complement of a hex digit is a hex digit")
|
|
})
|
|
.collect();
|
|
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
|
|
format!("{head}{tampered}{tail}")
|
|
}
|
|
|
|
fn assert_error_code(body: &str, code: &str) {
|
|
assert!(
|
|
body.contains(&format!("<Code>{code}</Code>")),
|
|
"expected S3 error code <Code>{code}</Code> in response body, got:\n{body}"
|
|
);
|
|
}
|
|
|
|
/// Replay a `PresignedRequest` faithfully: same method, same URI, forward every
|
|
/// signed header, attach an optional body. `reqwest` derives `Host` from the
|
|
/// URI (matching the signed host).
|
|
async fn send_presigned(pr: &PresignedRequest, body: Option<Vec<u8>>) -> reqwest::Result<reqwest::Response> {
|
|
send_raw(pr.method(), pr.uri(), pr.headers(), body).await
|
|
}
|
|
|
|
/// Replay against an ARBITRARY (possibly tampered) URI while keeping the signed
|
|
/// method/headers of the original presigned request.
|
|
async fn send_raw<'a>(
|
|
method: &str,
|
|
uri: &str,
|
|
headers: impl Iterator<Item = (&'a str, &'a str)>,
|
|
body: Option<Vec<u8>>,
|
|
) -> reqwest::Result<reqwest::Response> {
|
|
let method = reqwest::Method::from_bytes(method.as_bytes()).expect("valid HTTP method");
|
|
let mut builder = local_http_client().request(method, uri);
|
|
for (k, v) in headers {
|
|
builder = builder.header(k, v);
|
|
}
|
|
if let Some(body) = body {
|
|
builder = builder.body(body);
|
|
}
|
|
builder.send().await
|
|
}
|
|
|
|
async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
env.start_rustfs_server(vec![]).await?;
|
|
env.create_test_bucket(BUCKET).await?;
|
|
env.create_s3_client()
|
|
.put_object()
|
|
.bucket(BUCKET)
|
|
.key(CANONICAL_KEY)
|
|
.body(ByteStream::from_static(CANONICAL_BODY))
|
|
.send()
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Positive control (GET): a valid presigned GET must succeed and return the
|
|
/// stored bytes. Without this, every negative assertion could pass for the
|
|
/// wrong reason (a server that rejects all presigned URLs).
|
|
#[tokio::test]
|
|
async fn valid_presigned_get_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let pr = env
|
|
.create_s3_client()
|
|
.get_object()
|
|
.bucket(BUCKET)
|
|
.key(CANONICAL_KEY)
|
|
.presigned(valid_config())
|
|
.await?;
|
|
|
|
let resp = send_presigned(&pr, None).await?;
|
|
assert_eq!(resp.status().as_u16(), 200, "valid presigned GET should succeed");
|
|
let bytes = resp.bytes().await?;
|
|
assert_eq!(bytes.as_ref(), CANONICAL_BODY, "presigned GET body must match stored object");
|
|
info!("valid presigned GET control passed");
|
|
Ok(())
|
|
}
|
|
|
|
/// Positive control (PUT): a valid presigned PUT must store the object, which we
|
|
/// verify with a follow-up authenticated HEAD.
|
|
#[tokio::test]
|
|
async fn valid_presigned_put_succeeds() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let key = "presigned-put-ok.txt";
|
|
let body = b"stored-via-presigned-put".to_vec();
|
|
let pr = env
|
|
.create_s3_client()
|
|
.put_object()
|
|
.bucket(BUCKET)
|
|
.key(key)
|
|
.presigned(valid_config())
|
|
.await?;
|
|
|
|
let resp = send_presigned(&pr, Some(body.clone())).await?;
|
|
assert!(resp.status().is_success(), "valid presigned PUT should succeed, got {}", resp.status());
|
|
|
|
let head = env.create_s3_client().head_object().bucket(BUCKET).key(key).send().await?;
|
|
assert_eq!(head.content_length(), Some(body.len() as i64), "stored object length must match");
|
|
info!("valid presigned PUT control passed");
|
|
Ok(())
|
|
}
|
|
|
|
/// (a) An already-expired presigned GET must be rejected with 403 / AccessDenied
|
|
/// ("Request has expired"). s3s checks expiry BEFORE the signature, so the
|
|
/// signature here is otherwise valid — only the elapsed window is at fault.
|
|
#[tokio::test]
|
|
async fn expired_presigned_get_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let pr = env
|
|
.create_s3_client()
|
|
.get_object()
|
|
.bucket(BUCKET)
|
|
.key(CANONICAL_KEY)
|
|
.presigned(expired_config())
|
|
.await?;
|
|
|
|
let resp = send_presigned(&pr, None).await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status.as_u16(), 403, "expired presigned GET must be 403, body:\n{body}");
|
|
assert_error_code(&body, "AccessDenied");
|
|
Ok(())
|
|
}
|
|
|
|
/// (b) Tampering the `X-Amz-Signature` query value must be rejected with 403 /
|
|
/// SignatureDoesNotMatch.
|
|
#[tokio::test]
|
|
async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let pr = env
|
|
.create_s3_client()
|
|
.get_object()
|
|
.bucket(BUCKET)
|
|
.key(CANONICAL_KEY)
|
|
.presigned(valid_config())
|
|
.await?;
|
|
|
|
let tampered_uri = tamper_signature(pr.uri());
|
|
let resp = send_raw(pr.method(), &tampered_uri, pr.headers(), None).await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status.as_u16(), 403, "tampered presigned signature must be 403, body:\n{body}");
|
|
assert_error_code(&body, "SignatureDoesNotMatch");
|
|
Ok(())
|
|
}
|
|
|
|
/// (c) A presigned URL generated with the WRONG secret (but the real access key
|
|
/// id) must be rejected with 403 / SignatureDoesNotMatch.
|
|
#[tokio::test]
|
|
async fn wrong_secret_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let bad_client = s3_client_with_secret(&env, "totally-wrong-secret-key");
|
|
let pr = bad_client
|
|
.get_object()
|
|
.bucket(BUCKET)
|
|
.key(CANONICAL_KEY)
|
|
.presigned(valid_config())
|
|
.await?;
|
|
|
|
let resp = send_presigned(&pr, None).await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status.as_u16(), 403, "wrong-secret presigned URL must be 403, body:\n{body}");
|
|
assert_error_code(&body, "SignatureDoesNotMatch");
|
|
Ok(())
|
|
}
|
|
|
|
/// (d) Changing the signed target (the object key in the path) AFTER signing
|
|
/// must be rejected with 403 / SignatureDoesNotMatch: the presented request no
|
|
/// longer matches the canonical request the signature covers. The signature
|
|
/// check runs during auth, before any object lookup, so the swapped key need
|
|
/// not even exist.
|
|
#[tokio::test]
|
|
async fn tampered_target_key_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let signed_key = "signed-target.txt";
|
|
let served_key = "served-target.txt";
|
|
let pr = env
|
|
.create_s3_client()
|
|
.get_object()
|
|
.bucket(BUCKET)
|
|
.key(signed_key)
|
|
.presigned(valid_config())
|
|
.await?;
|
|
|
|
// Swap the object key in the path while leaving the (now stale) signature
|
|
// and its scope untouched.
|
|
let signed_segment = format!("/{signed_key}?");
|
|
let served_segment = format!("/{served_key}?");
|
|
let uri = pr.uri();
|
|
assert!(uri.contains(&signed_segment), "presigned uri must contain the signed key path: {uri}");
|
|
let tampered_uri = uri.replace(&signed_segment, &served_segment);
|
|
|
|
let resp = send_raw(pr.method(), &tampered_uri, pr.headers(), None).await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status.as_u16(), 403, "tampered target key must be 403, body:\n{body}");
|
|
assert_error_code(&body, "SignatureDoesNotMatch");
|
|
Ok(())
|
|
}
|
|
|
|
/// (e / acceptance 4 negative half) Tampering the signature of a presigned PUT
|
|
/// must be rejected with 403 / SignatureDoesNotMatch — the write must not land.
|
|
#[tokio::test]
|
|
async fn tampered_presigned_put_returns_signature_does_not_match() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
init_logging();
|
|
let mut env = RustFSTestEnvironment::new().await?;
|
|
setup(&mut env).await?;
|
|
|
|
let key = "presigned-put-tampered.txt";
|
|
let pr = env
|
|
.create_s3_client()
|
|
.put_object()
|
|
.bucket(BUCKET)
|
|
.key(key)
|
|
.presigned(valid_config())
|
|
.await?;
|
|
|
|
let tampered_uri = tamper_signature(pr.uri());
|
|
let resp = send_raw(pr.method(), &tampered_uri, pr.headers(), Some(b"should-not-be-stored".to_vec())).await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status.as_u16(), 403, "tampered presigned PUT must be 403, body:\n{body}");
|
|
assert_error_code(&body, "SignatureDoesNotMatch");
|
|
|
|
// The rejected write must not have created the object.
|
|
let error = env
|
|
.create_s3_client()
|
|
.head_object()
|
|
.bucket(BUCKET)
|
|
.key(key)
|
|
.send()
|
|
.await
|
|
.expect_err("tampered presigned PUT must not store the object");
|
|
assert_eq!(
|
|
error.raw_response().map(|response| response.status().as_u16()),
|
|
Some(404),
|
|
"tampered presigned PUT absence probe must return HTTP 404, got {error:?}"
|
|
);
|
|
Ok(())
|
|
}
|