mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-24 13:16:28 +00:00
feat(s3): reject write-offset-bytes requests compatibly (#2295)
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
use async_compression::tokio::write::{BzEncoder, XzEncoder};
|
use async_compression::tokio::write::{BzEncoder, XzEncoder};
|
||||||
use aws_sdk_s3::error::SdkError;
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||||
@@ -25,8 +25,13 @@ use base64::Engine;
|
|||||||
use chrono::{Duration as ChronoDuration, Utc};
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use flate2::{Compression, write::GzEncoder};
|
use flate2::{Compression, write::GzEncoder};
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::error::Error;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
@@ -154,10 +159,11 @@ async fn xz_bytes(data: &[u8]) -> Vec<u8> {
|
|||||||
encoder.into_inner().into_inner()
|
encoder.into_inner().into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_s3_error_code<T: std::fmt::Debug>(
|
fn assert_s3_error_code<T, E>(result: Result<T, SdkError<E>>, code: &str)
|
||||||
result: Result<T, aws_sdk_s3::error::SdkError<aws_sdk_s3::operation::put_object::PutObjectError>>,
|
where
|
||||||
code: &str,
|
T: std::fmt::Debug,
|
||||||
) {
|
E: ProvideErrorMetadata + std::fmt::Debug,
|
||||||
|
{
|
||||||
let err = result.expect_err("request should fail");
|
let err = result.expect_err("request should fail");
|
||||||
match err {
|
match err {
|
||||||
SdkError::ServiceError(service_err) => {
|
SdkError::ServiceError(service_err) => {
|
||||||
@@ -168,6 +174,43 @@ fn assert_s3_error_code<T: std::fmt::Debug>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn signed_raw_request(
|
||||||
|
method: http::Method,
|
||||||
|
url: &str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
body: Option<Vec<u8>>,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
extra_headers: &[(&str, &str)],
|
||||||
|
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
|
||||||
|
let mut request = http::Request::builder().method(method.clone()).uri(uri);
|
||||||
|
request = request.header(HOST, authority);
|
||||||
|
request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||||
|
if let Some(content_type) = content_type {
|
||||||
|
request = request.header(CONTENT_TYPE, content_type);
|
||||||
|
}
|
||||||
|
for (name, value) in extra_headers {
|
||||||
|
request = request.header(*name, *value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_len = body.as_ref().map(|value| value.len() as i64).unwrap_or_default();
|
||||||
|
let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
||||||
|
let client = local_http_client();
|
||||||
|
let mut request_builder = client.request(reqwest_method, url);
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
request_builder = request_builder.header(name, value);
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
request_builder = request_builder.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(request_builder.send().await?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn allow_anonymous_put_object(
|
async fn allow_anonymous_put_object(
|
||||||
client: &aws_sdk_s3::Client,
|
client: &aws_sdk_s3::Client,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -5117,6 +5160,99 @@ async fn test_signed_put_object_extract_rejects_invalid_storage_class() -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_signed_put_object_rejects_write_offset_bytes_header() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let bucket = "put-write-offset-reject";
|
||||||
|
let key = "write-offset-object";
|
||||||
|
|
||||||
|
let admin_client = env.create_s3_client();
|
||||||
|
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||||
|
|
||||||
|
let result = admin_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"write-offset-body"))
|
||||||
|
.customize()
|
||||||
|
.mutate_request(|req| {
|
||||||
|
req.headers_mut()
|
||||||
|
.insert("x-amz-write-offset-bytes", HeaderValue::from_static("0"));
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_s3_error_code(result, "NotImplemented");
|
||||||
|
|
||||||
|
let head_after_reject = admin_client.head_object().bucket(bucket).key(key).send().await;
|
||||||
|
match head_after_reject.expect_err("rejected request should not create the object") {
|
||||||
|
SdkError::ServiceError(service_err) => {
|
||||||
|
let s3_err = service_err.into_err();
|
||||||
|
assert!(
|
||||||
|
s3_err.meta().code() == Some("NoSuchKey") || s3_err.meta().code() == Some("NotFound"),
|
||||||
|
"expected the rejected write to leave no object behind, got: {s3_err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other_err => panic!("expected missing object error after rejected write, got: {other_err:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
admin_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"regular-put-body"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
admin_client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_raw_signed_put_object_write_offset_bytes_returns_minio_compatible_error_body()
|
||||||
|
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let bucket = "put-write-offset-raw";
|
||||||
|
let key = "write-offset-raw-object";
|
||||||
|
|
||||||
|
let admin_client = env.create_s3_client();
|
||||||
|
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||||
|
|
||||||
|
let response = signed_raw_request(
|
||||||
|
http::Method::PUT,
|
||||||
|
&format!("{}/{bucket}/{key}", env.url),
|
||||||
|
&env.access_key,
|
||||||
|
&env.secret_key,
|
||||||
|
Some(b"write-offset-body".to_vec()),
|
||||||
|
None,
|
||||||
|
&[("x-amz-write-offset-bytes", "0")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
|
||||||
|
assert_eq!(status, reqwest::StatusCode::NOT_IMPLEMENTED);
|
||||||
|
assert!(body.contains("<Code>NotImplemented</Code>"), "unexpected response body: {body}");
|
||||||
|
assert!(
|
||||||
|
body.contains("A header you provided implies functionality that is not implemented"),
|
||||||
|
"unexpected response body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use super::ecfs::FS;
|
use super::ecfs::FS;
|
||||||
use crate::auth::{check_key_valid, get_condition_values_with_query, get_session_token};
|
use crate::auth::{check_key_valid, get_condition_values_with_query, get_session_token};
|
||||||
|
use crate::error::ApiError;
|
||||||
use crate::license::license_check;
|
use crate::license::license_check;
|
||||||
use crate::server::RemoteAddr;
|
use crate::server::RemoteAddr;
|
||||||
use metrics::counter;
|
use metrics::counter;
|
||||||
@@ -65,6 +66,12 @@ fn ext_req_info_mut(ext: &mut http::Extensions) -> S3Result<&mut ReqInfo> {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct ObjectTagConditions(pub HashMap<String, Vec<String>>);
|
pub(crate) struct ObjectTagConditions(pub HashMap<String, Vec<String>>);
|
||||||
|
|
||||||
|
const AMZ_WRITE_OFFSET_BYTES_HEADER: &str = "x-amz-write-offset-bytes";
|
||||||
|
|
||||||
|
fn has_write_offset_bytes_header(headers: &http::HeaderMap) -> bool {
|
||||||
|
headers.contains_key(AMZ_WRITE_OFFSET_BYTES_HEADER)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the bucket has a policy that uses `s3:ExistingObjectTag` (or
|
/// Returns true if the bucket has a policy that uses `s3:ExistingObjectTag` (or
|
||||||
/// `ExistingObjectTag/...`) conditions. Used to skip fetching object tags when
|
/// `ExistingObjectTag/...`) conditions. Used to skip fetching object tags when
|
||||||
/// no tag-based policy is in effect.
|
/// no tag-based policy is in effect.
|
||||||
@@ -1436,6 +1443,13 @@ impl S3Access for FS {
|
|||||||
req_info.object = Some(req.input.key.clone());
|
req_info.object = Some(req.input.key.clone());
|
||||||
req_info.version_id = req.input.version_id.clone();
|
req_info.version_id = req.input.version_id.clone();
|
||||||
|
|
||||||
|
if has_write_offset_bytes_header(&req.headers) {
|
||||||
|
return Err(S3Error::with_message(
|
||||||
|
S3ErrorCode::NotImplemented,
|
||||||
|
ApiError::error_code_to_message(&S3ErrorCode::NotImplemented),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await?;
|
authorize_request(req, Action::S3Action(S3Action::PutObjectAction)).await?;
|
||||||
|
|
||||||
if legal_hold_write_requested(req.input.object_lock_legal_hold_status.as_ref()) {
|
if legal_hold_write_requested(req.input.object_lock_legal_hold_status.as_ref()) {
|
||||||
@@ -1782,4 +1796,12 @@ mod tests {
|
|||||||
"post object request should carry the marker for downstream handling"
|
"post object request should carry the marker for downstream handling"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_offset_bytes_header_detection_is_case_insensitive() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("X-Amz-Write-Offset-Bytes", http::HeaderValue::from_static("0"));
|
||||||
|
|
||||||
|
assert!(has_write_offset_bytes_header(&headers));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user