mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 11:56:38 +00:00
fix(webdav): allow bucket-scoped root listings (#6298)
* fix(webdav): allow bucket-scoped root listings * test(webdav): use public protocol export * test(webdav): initialize identity inline --------- Co-authored-by: cxymds <cxymds@gmail.com>
This commit is contained in:
@@ -233,6 +233,111 @@ pub async fn test_webdav_core_operations() -> Result<()> {
|
|||||||
);
|
);
|
||||||
info!("PASS: PUT file '{}' successful", filename);
|
info!("PASS: PUT file '{}' successful", filename);
|
||||||
|
|
||||||
|
// Regression for #6260: a bucket-scoped policy must be able to discover its bucket at the
|
||||||
|
// WebDAV root without the unrelated global ListAllMyBuckets permission.
|
||||||
|
let scoped_bucket = "webdav-scoped-bucket";
|
||||||
|
let scoped_file = "visible.txt";
|
||||||
|
let scoped_user = "webdav-scoped-user";
|
||||||
|
let scoped_secret = "webdav-scoped-secret";
|
||||||
|
let scoped_policy_name = "webdav-scoped-policy";
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request(reqwest::Method::from_bytes(b"MKCOL").unwrap(), format!("{}/{}", base_url, scoped_bucket))
|
||||||
|
.header("Authorization", &auth_header)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(resp.status().as_u16(), 201, "scoped test bucket should be created");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.put(format!("{}/{}/{}", base_url, scoped_bucket, scoped_file))
|
||||||
|
.header("Authorization", &auth_header)
|
||||||
|
.body("visible to the scoped principal")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(resp.status().as_u16(), 201, "scoped test object should be created");
|
||||||
|
|
||||||
|
admin_create_user(&admin_base_url, scoped_user, scoped_secret).await?;
|
||||||
|
admin_add_canned_policy(
|
||||||
|
&admin_base_url,
|
||||||
|
scoped_policy_name,
|
||||||
|
&serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": [
|
||||||
|
format!("arn:aws:s3:::{}", scoped_bucket),
|
||||||
|
format!("arn:aws:s3:::{}/*", scoped_bucket)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": [
|
||||||
|
format!("arn:aws:s3:::{}", scoped_bucket),
|
||||||
|
format!("arn:aws:s3:::{}/*", scoped_bucket)
|
||||||
|
],
|
||||||
|
"Condition": { "Bool": { "aws:SecureTransport": "true" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": [
|
||||||
|
format!("arn:aws:s3:::{}", scoped_bucket),
|
||||||
|
format!("arn:aws:s3:::{}/*", scoped_bucket)
|
||||||
|
],
|
||||||
|
"Condition": { "StringEquals": { "s3:signatureversion": "AWS4-HMAC-SHA256" } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
admin_attach_policy_to_user(&admin_base_url, scoped_policy_name, scoped_user).await?;
|
||||||
|
|
||||||
|
let scoped_auth = basic_auth_header_for(scoped_user, scoped_secret);
|
||||||
|
let resp = client
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &base_url)
|
||||||
|
.header("Authorization", &scoped_auth)
|
||||||
|
.header("Depth", "1")
|
||||||
|
.header("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(resp.status().as_u16(), 207, "bucket-scoped root PROPFIND should succeed");
|
||||||
|
let root_listing = resp.text().await?;
|
||||||
|
assert!(root_listing.contains(scoped_bucket), "the authorized bucket should be listed");
|
||||||
|
assert!(!root_listing.contains(bucket_name), "an unauthorized bucket must not be listed");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request(
|
||||||
|
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
|
||||||
|
format!("{}/{}", base_url, scoped_bucket),
|
||||||
|
)
|
||||||
|
.header("Authorization", &scoped_auth)
|
||||||
|
.header("Depth", "1")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(resp.status().as_u16(), 207, "authorized bucket PROPFIND should succeed");
|
||||||
|
assert!(resp.text().await?.contains(scoped_file), "the authorized object should be listed");
|
||||||
|
|
||||||
|
let denied_user = "webdav-no-buckets-user";
|
||||||
|
let denied_secret = "webdav-no-buckets-secret";
|
||||||
|
admin_create_user(&admin_base_url, denied_user, denied_secret).await?;
|
||||||
|
let resp = client
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &base_url)
|
||||||
|
.header("Authorization", basic_auth_header_for(denied_user, denied_secret))
|
||||||
|
.header("Depth", "1")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status().as_u16(),
|
||||||
|
207,
|
||||||
|
"PROPFIND keeps the root resource visible when the directory listing is forbidden"
|
||||||
|
);
|
||||||
|
let denied_body = resp.text().await?;
|
||||||
|
assert!(!denied_body.contains(scoped_bucket), "a denied response must not leak the scoped bucket");
|
||||||
|
assert!(!denied_body.contains(bucket_name), "a denied response must not leak the admin bucket");
|
||||||
|
|
||||||
// Test GET (download file)
|
// Test GET (download file)
|
||||||
info!("Testing WebDAV: GET (download file '{}')", filename);
|
info!("Testing WebDAV: GET (download file '{}')", filename);
|
||||||
let resp = client
|
let resp = client
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ swift = [
|
|||||||
"dep:base64",
|
"dep:base64",
|
||||||
"dep:async-compression",
|
"dep:async-compression",
|
||||||
]
|
]
|
||||||
webdav = ["dep:dav-server", "dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:tokio-rustls", "dep:base64", "dep:rustls", "dep:percent-encoding", "dep:rustfs-tls-runtime", "dep:subtle"]
|
webdav = ["dep:dav-server", "dep:hyper", "dep:hyper-util", "dep:http", "dep:http-body-util", "dep:tokio-rustls", "dep:base64", "dep:rustls", "dep:percent-encoding", "dep:rustfs-tls-runtime", "dep:subtle"]
|
||||||
sftp = ["dep:russh", "dep:russh-sftp", "dep:uuid", "dep:subtle", "dep:tokio-util", "dep:socket2"]
|
sftp = ["dep:russh", "dep:russh-sftp", "dep:uuid", "dep:subtle", "dep:tokio-util", "dep:socket2"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use s3s::dto::*;
|
use s3s::dto::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
use crate::common::session::SessionContext;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait StorageBackend: Send + Sync {
|
pub trait StorageBackend: Send + Sync {
|
||||||
/// Error type for this storage backend
|
/// Error type for this storage backend
|
||||||
@@ -65,8 +68,24 @@ pub trait StorageBackend: Send + Sync {
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<ListObjectsV2Output, Self::Error>;
|
) -> Result<ListObjectsV2Output, Self::Error>;
|
||||||
/// List all buckets (requires authentication)
|
/// List all buckets (requires authentication).
|
||||||
async fn list_buckets(&self, access_key: &str, secret_key: &str) -> Result<ListBucketsOutput, Self::Error>;
|
async fn list_buckets(&self, access_key: &str, secret_key: &str) -> Result<ListBucketsOutput, Self::Error>;
|
||||||
|
/// List buckets visible to the authenticated session.
|
||||||
|
///
|
||||||
|
/// Backends that implement this must apply per-bucket authorization. The default denies the
|
||||||
|
/// request so existing backends cannot expose unfiltered bucket names.
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
async fn list_buckets_for_session(
|
||||||
|
&self,
|
||||||
|
_session_context: &SessionContext,
|
||||||
|
_request_headers: &http::HeaderMap,
|
||||||
|
_secure_transport: bool,
|
||||||
|
) -> s3s::S3Result<ListBucketsOutput> {
|
||||||
|
Err(s3s::S3Error::with_message(
|
||||||
|
s3s::S3ErrorCode::AccessDenied,
|
||||||
|
"Session-aware bucket listing is not supported",
|
||||||
|
))
|
||||||
|
}
|
||||||
/// Create a new bucket
|
/// Create a new bucket
|
||||||
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error>;
|
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error>;
|
||||||
/// Delete a bucket (must be empty)
|
/// Delete a bucket (must be empty)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
//! SessionContext type in common::session.
|
//! SessionContext type in common::session.
|
||||||
|
|
||||||
use crate::common::client::s3::StorageBackend;
|
use crate::common::client::s3::StorageBackend;
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
use crate::common::session::SessionContext;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_util::stream::{self, StreamExt};
|
use futures_util::stream::{self, StreamExt};
|
||||||
@@ -140,6 +142,8 @@ struct Inner {
|
|||||||
head_bucket: VecDeque<Result<HeadBucketOutput, DummyError>>,
|
head_bucket: VecDeque<Result<HeadBucketOutput, DummyError>>,
|
||||||
list_objects_v2: VecDeque<Result<ListObjectsV2Output, DummyError>>,
|
list_objects_v2: VecDeque<Result<ListObjectsV2Output, DummyError>>,
|
||||||
list_buckets: VecDeque<Result<ListBucketsOutput, DummyError>>,
|
list_buckets: VecDeque<Result<ListBucketsOutput, DummyError>>,
|
||||||
|
session_list_buckets: VecDeque<s3s::S3Result<ListBucketsOutput>>,
|
||||||
|
last_session_list_context: Option<(http::HeaderMap, bool)>,
|
||||||
create_bucket: VecDeque<Result<CreateBucketOutput, DummyError>>,
|
create_bucket: VecDeque<Result<CreateBucketOutput, DummyError>>,
|
||||||
delete_bucket: VecDeque<Result<DeleteBucketOutput, DummyError>>,
|
delete_bucket: VecDeque<Result<DeleteBucketOutput, DummyError>>,
|
||||||
copy_object: VecDeque<Result<CopyObjectOutput, DummyError>>,
|
copy_object: VecDeque<Result<CopyObjectOutput, DummyError>>,
|
||||||
@@ -193,6 +197,8 @@ impl Inner {
|
|||||||
head_bucket: VecDeque::new(),
|
head_bucket: VecDeque::new(),
|
||||||
list_objects_v2: VecDeque::new(),
|
list_objects_v2: VecDeque::new(),
|
||||||
list_buckets: VecDeque::new(),
|
list_buckets: VecDeque::new(),
|
||||||
|
session_list_buckets: VecDeque::new(),
|
||||||
|
last_session_list_context: None,
|
||||||
create_bucket: VecDeque::new(),
|
create_bucket: VecDeque::new(),
|
||||||
delete_bucket: VecDeque::new(),
|
delete_bucket: VecDeque::new(),
|
||||||
copy_object: VecDeque::new(),
|
copy_object: VecDeque::new(),
|
||||||
@@ -301,6 +307,31 @@ impl DummyBackend {
|
|||||||
.push_back(Ok(CreateBucketOutput::default()));
|
.push_back(Ok(CreateBucketOutput::default()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queue a legacy list_buckets response.
|
||||||
|
pub fn queue_list_buckets_ok(&self, output: ListBucketsOutput) {
|
||||||
|
self.inner.lock().expect("lock").list_buckets.push_back(Ok(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a legacy list_buckets error.
|
||||||
|
pub fn queue_list_buckets_err(&self, error: DummyError) {
|
||||||
|
self.inner.lock().expect("lock").list_buckets.push_back(Err(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a session-aware list_buckets response.
|
||||||
|
pub fn queue_session_list_buckets_ok(&self, output: ListBucketsOutput) {
|
||||||
|
self.inner.lock().expect("lock").session_list_buckets.push_back(Ok(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a session-aware list_buckets error.
|
||||||
|
pub fn queue_session_list_buckets_err(&self, error: s3s::S3Error) {
|
||||||
|
self.inner.lock().expect("lock").session_list_buckets.push_back(Err(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the context from the last session-aware list_buckets request.
|
||||||
|
pub fn last_session_list_context(&self) -> Option<(http::HeaderMap, bool)> {
|
||||||
|
self.inner.lock().expect("lock").last_session_list_context.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue a put_object error. Used by the commit_write retry tests
|
/// Queue a put_object error. Used by the commit_write retry tests
|
||||||
/// to script SlowDown / AccessDenied sequences against the
|
/// to script SlowDown / AccessDenied sequences against the
|
||||||
/// rustfs_utils::retry::is_s3code_in_message_retryable predicate.
|
/// rustfs_utils::retry::is_s3code_in_message_retryable predicate.
|
||||||
@@ -690,13 +721,29 @@ impl StorageBackend for DummyBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_buckets(&self, _ak: &str, _sk: &str) -> Result<ListBucketsOutput, Self::Error> {
|
async fn list_buckets(&self, _access_key: &str, _secret_key: &str) -> Result<ListBucketsOutput, Self::Error> {
|
||||||
match self.inner.lock().expect("lock").list_buckets.pop_front() {
|
match self.inner.lock().expect("lock").list_buckets.pop_front() {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => Ok(ListBucketsOutput::default()),
|
None => Ok(ListBucketsOutput::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
async fn list_buckets_for_session(
|
||||||
|
&self,
|
||||||
|
session_context: &SessionContext,
|
||||||
|
request_headers: &http::HeaderMap,
|
||||||
|
secure_transport: bool,
|
||||||
|
) -> s3s::S3Result<ListBucketsOutput> {
|
||||||
|
let _ = session_context;
|
||||||
|
let mut inner = self.inner.lock().expect("lock");
|
||||||
|
inner.last_session_list_context = Some((request_headers.clone(), secure_transport));
|
||||||
|
inner
|
||||||
|
.session_list_buckets
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_else(|| Ok(ListBucketsOutput::default()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_bucket(&self, _bucket: &str, _ak: &str, _sk: &str) -> Result<CreateBucketOutput, Self::Error> {
|
async fn create_bucket(&self, _bucket: &str, _ak: &str, _sk: &str) -> Result<CreateBucketOutput, Self::Error> {
|
||||||
match self.inner.lock().expect("lock").create_bucket.pop_front() {
|
match self.inner.lock().expect("lock").create_bucket.pop_front() {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
||||||
use crate::common::gateway::{S3Action, authorize_operation};
|
use crate::common::gateway::{AuthorizationError, S3Action, authorize_operation};
|
||||||
use crate::common::session::SessionContext;
|
use crate::common::session::SessionContext;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use dav_server::davpath::DavPath;
|
use dav_server::davpath::DavPath;
|
||||||
@@ -24,6 +24,7 @@ use futures_util::{FutureExt, StreamExt, stream};
|
|||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use rustfs_utils::MaskedAccessKey;
|
use rustfs_utils::MaskedAccessKey;
|
||||||
use rustfs_utils::path;
|
use rustfs_utils::path;
|
||||||
|
use s3s::S3ErrorCode;
|
||||||
use s3s::dto::*;
|
use s3s::dto::*;
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::io::SeekFrom;
|
use std::io::SeekFrom;
|
||||||
@@ -457,6 +458,10 @@ where
|
|||||||
storage: S,
|
storage: S,
|
||||||
/// Session context for authorization
|
/// Session context for authorization
|
||||||
session_context: Arc<SessionContext>,
|
session_context: Arc<SessionContext>,
|
||||||
|
/// Policy-safe WebDAV request headers used by IAM conditions.
|
||||||
|
request_headers: Option<http::HeaderMap>,
|
||||||
|
/// Whether the WebDAV connection uses TLS.
|
||||||
|
secure_transport: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResolvedPath {
|
enum ResolvedPath {
|
||||||
@@ -490,6 +495,8 @@ where
|
|||||||
Self {
|
Self {
|
||||||
storage: self.storage.clone(),
|
storage: self.storage.clone(),
|
||||||
session_context: self.session_context.clone(),
|
session_context: self.session_context.clone(),
|
||||||
|
request_headers: self.request_headers.clone(),
|
||||||
|
secure_transport: self.secure_transport,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,9 +510,18 @@ where
|
|||||||
Self {
|
Self {
|
||||||
storage,
|
storage,
|
||||||
session_context,
|
session_context,
|
||||||
|
request_headers: None,
|
||||||
|
secure_transport: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the request context used by IAM policy conditions.
|
||||||
|
pub fn with_request_context(mut self, request_headers: http::HeaderMap, secure_transport: bool) -> Self {
|
||||||
|
self.request_headers = Some(request_headers);
|
||||||
|
self.secure_transport = secure_transport;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn credentials(&self) -> (&str, &str) {
|
fn credentials(&self) -> (&str, &str) {
|
||||||
(
|
(
|
||||||
&self.session_context.principal.user_identity.credentials.access_key,
|
&self.session_context.principal.user_identity.credentials.access_key,
|
||||||
@@ -799,50 +815,41 @@ where
|
|||||||
/// List all buckets (for root path)
|
/// List all buckets (for root path)
|
||||||
async fn list_buckets(&self) -> FsResult<Vec<WebDavDirEntry>> {
|
async fn list_buckets(&self) -> FsResult<Vec<WebDavDirEntry>> {
|
||||||
match authorize_operation(&self.session_context, &S3Action::ListBuckets, "", None).await {
|
match authorize_operation(&self.session_context, &S3Action::ListBuckets, "", None).await {
|
||||||
Ok(_) => {}
|
Ok(()) => {
|
||||||
Err(_e) => {
|
let (access_key, secret_key) = self.credentials();
|
||||||
return Err(FsError::Forbidden);
|
return match self.storage.list_buckets(access_key, secret_key).await {
|
||||||
|
Ok(output) => Ok(Self::bucket_entries(output)),
|
||||||
|
Err(error) => {
|
||||||
|
error!(
|
||||||
|
event = EVENT_WEBDAV_BUCKET_LIST_FAILED,
|
||||||
|
component = LOG_COMPONENT_PROTOCOLS,
|
||||||
|
subsystem = LOG_SUBSYSTEM_WEBDAV_DRIVER,
|
||||||
|
error = %error,
|
||||||
|
access_key = %MaskedAccessKey(access_key),
|
||||||
|
"webdav bucket list failed"
|
||||||
|
);
|
||||||
|
Err(FsError::GeneralFailure)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Err(AuthorizationError::AccessDenied) => {}
|
||||||
|
Err(AuthorizationError::IamUnavailable) => return Err(FsError::GeneralFailure),
|
||||||
}
|
}
|
||||||
|
|
||||||
match self
|
let Some(request_headers) = self.request_headers.as_ref() else {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
};
|
||||||
|
let result = self
|
||||||
.storage
|
.storage
|
||||||
.list_buckets(
|
.list_buckets_for_session(&self.session_context, request_headers, self.secure_transport)
|
||||||
&self.session_context.principal.user_identity.credentials.access_key,
|
.await;
|
||||||
&self.session_context.principal.user_identity.credentials.secret_key,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(output) => {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
if let Some(buckets) = output.buckets {
|
|
||||||
for bucket in buckets {
|
|
||||||
if let Some(ref bucket_name) = bucket.name {
|
|
||||||
let modified = bucket
|
|
||||||
.creation_date
|
|
||||||
.map(|dt| {
|
|
||||||
let offset_dt: time::OffsetDateTime = dt.into();
|
|
||||||
SystemTime::from(offset_dt)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(SystemTime::now);
|
|
||||||
|
|
||||||
entries.push(WebDavDirEntry {
|
match result {
|
||||||
name: bucket_name.clone(),
|
Ok(output) => Ok(Self::bucket_entries(output)),
|
||||||
metadata: WebDavMetaData {
|
|
||||||
size: 0,
|
|
||||||
modified,
|
|
||||||
created: modified,
|
|
||||||
is_dir: true,
|
|
||||||
etag: None,
|
|
||||||
content_type: None,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(entries)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
if matches!(e.code(), S3ErrorCode::AccessDenied) {
|
||||||
|
return Err(FsError::Forbidden);
|
||||||
|
}
|
||||||
error!(
|
error!(
|
||||||
event = EVENT_WEBDAV_BUCKET_LIST_FAILED,
|
event = EVENT_WEBDAV_BUCKET_LIST_FAILED,
|
||||||
component = LOG_COMPONENT_PROTOCOLS,
|
component = LOG_COMPONENT_PROTOCOLS,
|
||||||
@@ -856,6 +863,35 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bucket_entries(output: ListBucketsOutput) -> Vec<WebDavDirEntry> {
|
||||||
|
output
|
||||||
|
.buckets
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|bucket| {
|
||||||
|
let name = bucket.name?;
|
||||||
|
let modified = bucket
|
||||||
|
.creation_date
|
||||||
|
.map(|date| {
|
||||||
|
let date: time::OffsetDateTime = date.into();
|
||||||
|
SystemTime::from(date)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(SystemTime::now);
|
||||||
|
Some(WebDavDirEntry {
|
||||||
|
name,
|
||||||
|
metadata: WebDavMetaData {
|
||||||
|
size: 0,
|
||||||
|
modified,
|
||||||
|
created: modified,
|
||||||
|
is_dir: true,
|
||||||
|
etag: None,
|
||||||
|
content_type: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// List objects in a bucket
|
/// List objects in a bucket
|
||||||
async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> FsResult<Vec<WebDavDirEntry>> {
|
async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> FsResult<Vec<WebDavDirEntry>> {
|
||||||
// Authorize the operation
|
// Authorize the operation
|
||||||
@@ -1715,8 +1751,9 @@ where
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::WebDavDriver;
|
use super::WebDavDriver;
|
||||||
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
use crate::common::client::s3::StorageBackend as S3StorageBackend;
|
||||||
use crate::common::gateway::{S3Action, with_test_auth_override};
|
use crate::common::dummy_storage::DummyBackend;
|
||||||
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext};
|
use crate::common::gateway::{S3Action, with_test_auth_override, with_test_iam_unavailable};
|
||||||
|
use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext, test_session};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use dav_server::davpath::DavPath;
|
use dav_server::davpath::DavPath;
|
||||||
@@ -1906,6 +1943,134 @@ mod tests {
|
|||||||
WebDavDriver::new(DummyStorage, Arc::new(session_context))
|
WebDavDriver::new(DummyStorage, Arc::new(session_context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_bucket_listing_does_not_require_global_list_permission() {
|
||||||
|
let storage = DummyBackend::new();
|
||||||
|
storage.queue_session_list_buckets_ok(ListBucketsOutput {
|
||||||
|
buckets: Some(vec![Bucket {
|
||||||
|
name: Some("allowed-bucket".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let driver = WebDavDriver::new(storage.clone(), Arc::new(test_session(Protocol::WebDav))).with_request_context(
|
||||||
|
http::HeaderMap::from_iter([(http::header::USER_AGENT, http::HeaderValue::from_static("webdav-test"))]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let entries = with_test_auth_override(|_, _, _| false, driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect("session-aware backend should own bucket filtering");
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].name, "allowed-bucket");
|
||||||
|
let (headers, secure_transport) = storage
|
||||||
|
.last_session_list_context()
|
||||||
|
.expect("request context should be forwarded");
|
||||||
|
assert_eq!(headers.get("user-agent").expect("user agent"), "webdav-test");
|
||||||
|
assert!(!secure_transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_buckets_maps_typed_access_denied_to_forbidden() {
|
||||||
|
let storage = DummyBackend::new();
|
||||||
|
storage.queue_session_list_buckets_err(s3s::S3Error::with_message(s3s::S3ErrorCode::AccessDenied, "policy denied"));
|
||||||
|
let driver = WebDavDriver::new(storage, Arc::new(test_session(Protocol::WebDav)))
|
||||||
|
.with_request_context(http::HeaderMap::new(), false);
|
||||||
|
|
||||||
|
let error = with_test_auth_override(|_, _, _| false, driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect_err("bucket listing should be denied");
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_buckets_does_not_classify_error_text_as_access_denied() {
|
||||||
|
let storage = DummyBackend::new();
|
||||||
|
storage.queue_session_list_buckets_err(s3s::S3Error::with_message(
|
||||||
|
s3s::S3ErrorCode::InternalError,
|
||||||
|
"AccessDenied appears only in the message",
|
||||||
|
));
|
||||||
|
let driver = WebDavDriver::new(storage, Arc::new(test_session(Protocol::WebDav)))
|
||||||
|
.with_request_context(http::HeaderMap::new(), false);
|
||||||
|
|
||||||
|
let error = with_test_auth_override(|_, _, _| false, driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect_err("bucket listing should fail");
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::GeneralFailure));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn global_list_permission_keeps_the_legacy_backend_path() {
|
||||||
|
let storage = DummyBackend::new();
|
||||||
|
storage.queue_list_buckets_ok(ListBucketsOutput {
|
||||||
|
buckets: Some(vec![Bucket {
|
||||||
|
name: Some("legacy-bucket".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let driver = WebDavDriver::new(storage.clone(), Arc::new(test_session(Protocol::WebDav)));
|
||||||
|
|
||||||
|
let entries = with_test_auth_override(|_, _, _| true, driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect("globally authorized legacy backend should keep working");
|
||||||
|
|
||||||
|
assert_eq!(entries[0].name, "legacy-bucket");
|
||||||
|
assert!(storage.last_session_list_context().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_bucket_list_error_is_a_general_failure() {
|
||||||
|
let storage = DummyBackend::new();
|
||||||
|
storage.queue_list_buckets_err(crate::common::dummy_storage::DummyError::Injected("backend failed".to_string()));
|
||||||
|
let driver = WebDavDriver::new(storage.clone(), Arc::new(test_session(Protocol::WebDav)));
|
||||||
|
|
||||||
|
let error = with_test_auth_override(|_, _, _| true, driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect_err("legacy backend error should fail the listing");
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::GeneralFailure));
|
||||||
|
assert!(storage.last_session_list_context().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn iam_unavailable_does_not_enter_the_session_fallback() {
|
||||||
|
let storage = DummyBackend::new();
|
||||||
|
storage.queue_session_list_buckets_ok(ListBucketsOutput::default());
|
||||||
|
let driver = WebDavDriver::new(storage.clone(), Arc::new(test_session(Protocol::WebDav)))
|
||||||
|
.with_request_context(http::HeaderMap::new(), false);
|
||||||
|
|
||||||
|
let error = with_test_iam_unavailable(driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect_err("IAM outage must fail closed");
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::GeneralFailure));
|
||||||
|
assert!(storage.last_session_list_context().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_request_context_fails_closed() {
|
||||||
|
let error = with_test_auth_override(|_, _, _| false, driver().list_buckets())
|
||||||
|
.await
|
||||||
|
.expect_err("bucket listing should require the original request context");
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn backend_without_session_listing_fails_closed() {
|
||||||
|
let driver = driver().with_request_context(http::HeaderMap::new(), false);
|
||||||
|
|
||||||
|
let error = with_test_auth_override(|_, _, _| false, driver.list_buckets())
|
||||||
|
.await
|
||||||
|
.expect_err("default session listing must deny the request");
|
||||||
|
|
||||||
|
assert!(matches!(error, FsError::Forbidden));
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RecordingStorageState {
|
struct RecordingStorageState {
|
||||||
objects: HashMap<(String, String), Vec<u8>>,
|
objects: HashMap<(String, String), Vec<u8>>,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext, is_tem
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use dav_server::DavHandler;
|
use dav_server::DavHandler;
|
||||||
use dav_server::fakels::FakeLs;
|
use dav_server::fakels::FakeLs;
|
||||||
|
use http::header::{AUTHORIZATION, REFERER, USER_AGENT};
|
||||||
|
use http::{HeaderMap, HeaderValue};
|
||||||
use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
|
use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
|
||||||
use hyper::body::Body as HttpBody;
|
use hyper::body::Body as HttpBody;
|
||||||
use hyper::server::conn::http1;
|
use hyper::server::conn::http1;
|
||||||
@@ -59,6 +61,20 @@ const EVENT_WEBDAV_CONNECTION_CAP_STATE: &str = "webdav_connection_cap_state";
|
|||||||
/// materialise a whole object in memory for every GET.
|
/// materialise a whole object in memory for every GET.
|
||||||
type WebDavBody = Pin<Box<dyn HttpBody<Data = Bytes, Error = io::Error> + Send>>;
|
type WebDavBody = Pin<Box<dyn HttpBody<Data = Bytes, Error = io::Error> + Send>>;
|
||||||
|
|
||||||
|
fn policy_request_headers(headers: &HeaderMap) -> HeaderMap {
|
||||||
|
let mut policy_headers = HeaderMap::new();
|
||||||
|
for name in [USER_AGENT, REFERER] {
|
||||||
|
if let Some(value) = headers.get(&name) {
|
||||||
|
policy_headers.insert(name, value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut authorization = HeaderValue::from_static("Basic");
|
||||||
|
authorization.set_sensitive(true);
|
||||||
|
policy_headers.insert(AUTHORIZATION, authorization);
|
||||||
|
policy_headers
|
||||||
|
}
|
||||||
|
|
||||||
/// WebDAV server implementation
|
/// WebDAV server implementation
|
||||||
pub struct WebDavServer<S>
|
pub struct WebDavServer<S>
|
||||||
where
|
where
|
||||||
@@ -216,7 +232,7 @@ where
|
|||||||
match timeout(request_timeout, acceptor.accept(stream)).await {
|
match timeout(request_timeout, acceptor.accept(stream)).await {
|
||||||
Ok(Ok(tls_stream)) => {
|
Ok(Ok(tls_stream)) => {
|
||||||
let io = TokioIo::new(tls_stream);
|
let io = TokioIo::new(tls_stream);
|
||||||
if let Err(e) = Self::handle_connection_impl(io, storage, source_ip, max_body_size, request_timeout).await {
|
if let Err(e) = Self::handle_connection_impl(io, storage, source_ip, true, max_body_size, request_timeout).await {
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_WEBDAV_CONNECTION_STATE,
|
event = EVENT_WEBDAV_CONNECTION_STATE,
|
||||||
component = LOG_COMPONENT_PROTOCOLS,
|
component = LOG_COMPONENT_PROTOCOLS,
|
||||||
@@ -254,7 +270,7 @@ where
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let io = TokioIo::new(stream);
|
let io = TokioIo::new(stream);
|
||||||
if let Err(e) = Self::handle_connection_impl(io, storage, source_ip, max_body_size, request_timeout).await {
|
if let Err(e) = Self::handle_connection_impl(io, storage, source_ip, false, max_body_size, request_timeout).await {
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_WEBDAV_CONNECTION_STATE,
|
event = EVENT_WEBDAV_CONNECTION_STATE,
|
||||||
component = LOG_COMPONENT_PROTOCOLS,
|
component = LOG_COMPONENT_PROTOCOLS,
|
||||||
@@ -313,6 +329,7 @@ where
|
|||||||
io: TokioIo<I>,
|
io: TokioIo<I>,
|
||||||
storage: S,
|
storage: S,
|
||||||
source_ip: IpAddr,
|
source_ip: IpAddr,
|
||||||
|
secure_transport: bool,
|
||||||
max_body_size: u64,
|
max_body_size: u64,
|
||||||
request_timeout: Duration,
|
request_timeout: Duration,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
@@ -321,7 +338,7 @@ where
|
|||||||
{
|
{
|
||||||
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
|
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
|
||||||
let storage = storage.clone();
|
let storage = storage.clone();
|
||||||
async move { Self::handle_request(req, storage, source_ip, max_body_size, request_timeout).await }
|
async move { Self::handle_request(req, storage, source_ip, secure_transport, max_body_size, request_timeout).await }
|
||||||
});
|
});
|
||||||
|
|
||||||
// A peer that opens a connection and dribbles (or never finishes)
|
// A peer that opens a connection and dribbles (or never finishes)
|
||||||
@@ -341,6 +358,7 @@ where
|
|||||||
req: Request<hyper::body::Incoming>,
|
req: Request<hyper::body::Incoming>,
|
||||||
storage: S,
|
storage: S,
|
||||||
source_ip: IpAddr,
|
source_ip: IpAddr,
|
||||||
|
secure_transport: bool,
|
||||||
max_body_size: u64,
|
max_body_size: u64,
|
||||||
request_timeout: Duration,
|
request_timeout: Duration,
|
||||||
) -> Result<Response<WebDavBody>, Infallible> {
|
) -> Result<Response<WebDavBody>, Infallible> {
|
||||||
@@ -398,7 +416,8 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create WebDAV driver with session context
|
// Create WebDAV driver with session context
|
||||||
let driver = WebDavDriver::new(storage, Arc::new(session_context));
|
let driver = WebDavDriver::new(storage, Arc::new(session_context))
|
||||||
|
.with_request_context(policy_request_headers(req.headers()), secure_transport);
|
||||||
|
|
||||||
// Build DAV handler with boxed filesystem
|
// Build DAV handler with boxed filesystem
|
||||||
let dav_handler = DavHandler::builder()
|
let dav_handler = DavHandler::builder()
|
||||||
@@ -883,6 +902,30 @@ mod tests {
|
|||||||
.expect("build get request")
|
.expect("build get request")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_headers_drop_credentials_and_s3_auth_spoofing() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic dXNlcjpwYXNzd29yZA=="));
|
||||||
|
headers.insert(USER_AGENT, HeaderValue::from_static("webdav-client"));
|
||||||
|
headers.insert(REFERER, HeaderValue::from_static("https://example.test/"));
|
||||||
|
headers.insert("x-amz-content-sha256", HeaderValue::from_static("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"));
|
||||||
|
headers.insert("x-amz-signature-age", HeaderValue::from_static("0"));
|
||||||
|
|
||||||
|
let policy_headers = policy_request_headers(&headers);
|
||||||
|
|
||||||
|
assert_eq!(policy_headers.get(AUTHORIZATION).expect("authorization marker"), "Basic");
|
||||||
|
assert!(
|
||||||
|
policy_headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.expect("authorization marker")
|
||||||
|
.is_sensitive()
|
||||||
|
);
|
||||||
|
assert_eq!(policy_headers.get(USER_AGENT).expect("user agent"), "webdav-client");
|
||||||
|
assert_eq!(policy_headers.get(REFERER).expect("referer"), "https://example.test/");
|
||||||
|
assert!(!policy_headers.contains_key("x-amz-content-sha256"));
|
||||||
|
assert!(!policy_headers.contains_key("x-amz-signature-age"));
|
||||||
|
}
|
||||||
|
|
||||||
/// R03-CAN-051 / R03-CAN-067 / R05-CAN-094: a chunked upload declares no
|
/// R03-CAN-051 / R03-CAN-067 / R05-CAN-094: a chunked upload declares no
|
||||||
/// Content-Length, so the limit has to hold on the bytes actually read.
|
/// Content-Length, so the limit has to hold on the bytes actually read.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -959,6 +1002,7 @@ mod tests {
|
|||||||
TokioIo::new(server),
|
TokioIo::new(server),
|
||||||
StubStorage,
|
StubStorage,
|
||||||
TEST_IP,
|
TEST_IP,
|
||||||
|
false,
|
||||||
1024,
|
1024,
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -13,10 +13,16 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::runtime_sources::current_action_credentials;
|
use crate::runtime_sources::current_action_credentials;
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
use crate::shared_types::RemoteAddr;
|
||||||
use crate::storage_api::protocols::client::{FS, ReqInfo, RequestContext};
|
use crate::storage_api::protocols::client::{FS, ReqInfo, RequestContext};
|
||||||
use http::{HeaderMap, Method};
|
use http::{HeaderMap, Method};
|
||||||
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
|
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
|
||||||
use rustfs_credentials;
|
use rustfs_credentials;
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
use rustfs_protocols::common::SessionContext;
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
use rustfs_trusted_proxies::ClientInfo;
|
||||||
use rustfs_utils::MaskedAccessKey;
|
use rustfs_utils::MaskedAccessKey;
|
||||||
use s3s::dto::*;
|
use s3s::dto::*;
|
||||||
use s3s::{S3, S3Request, S3Result};
|
use s3s::{S3, S3Request, S3Result};
|
||||||
@@ -90,6 +96,50 @@ fn trace_protocol_request(operation: &str, bucket: Option<&str>, object: Option<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
fn session_list_buckets_request(
|
||||||
|
input: ListBucketsInput,
|
||||||
|
session_context: &SessionContext,
|
||||||
|
request_headers: &HeaderMap,
|
||||||
|
secure_transport: bool,
|
||||||
|
) -> S3Request<ListBucketsInput> {
|
||||||
|
let credentials = &session_context.principal.user_identity.credentials;
|
||||||
|
let mut extensions = http::Extensions::default();
|
||||||
|
let remote_addr = std::net::SocketAddr::new(session_context.source_ip, 0);
|
||||||
|
extensions.insert(Some(RemoteAddr(remote_addr)));
|
||||||
|
let mut client_info = ClientInfo::direct(remote_addr);
|
||||||
|
client_info.forwarded_proto = Some(if secure_transport { "https" } else { "http" }.to_string());
|
||||||
|
extensions.insert(client_info);
|
||||||
|
|
||||||
|
let is_owner = current_action_credentials().is_some_and(|global_cred| credentials.access_key == global_cred.access_key);
|
||||||
|
extensions.insert(ReqInfo {
|
||||||
|
cred: Some(credentials.clone()),
|
||||||
|
is_owner,
|
||||||
|
bucket: None,
|
||||||
|
object: None,
|
||||||
|
version_id: None,
|
||||||
|
replication_request_authorized: false,
|
||||||
|
region: None,
|
||||||
|
request_context: Some(RequestContext::fallback()),
|
||||||
|
suppress_denial_log: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
S3Request {
|
||||||
|
input,
|
||||||
|
method: Method::GET,
|
||||||
|
uri: http::Uri::from_static("/"),
|
||||||
|
headers: request_headers.clone(),
|
||||||
|
extensions,
|
||||||
|
credentials: Some(s3s::auth::Credentials {
|
||||||
|
access_key: credentials.access_key.clone(),
|
||||||
|
secret_key: credentials.secret_key.clone().into(),
|
||||||
|
}),
|
||||||
|
region: None,
|
||||||
|
service: None,
|
||||||
|
trailing_headers: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_bucket_uri(bucket: &str, query: &[(&str, Option<&str>)]) -> S3Result<http::Uri> {
|
fn build_bucket_uri(bucket: &str, query: &[(&str, Option<&str>)]) -> S3Result<http::Uri> {
|
||||||
let mut uri = format!("/{}", encode_path_segment(bucket));
|
let mut uri = format!("/{}", encode_path_segment(bucket));
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
@@ -469,6 +519,29 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
async fn list_buckets_for_session(
|
||||||
|
&self,
|
||||||
|
session_context: &SessionContext,
|
||||||
|
request_headers: &HeaderMap,
|
||||||
|
secure_transport: bool,
|
||||||
|
) -> Result<ListBucketsOutput, Self::Error> {
|
||||||
|
trace!(
|
||||||
|
event = EVENT_PROTOCOL_STORAGE_CLIENT_REQUEST,
|
||||||
|
component = LOG_COMPONENT_PROTOCOLS,
|
||||||
|
subsystem = LOG_SUBSYSTEM_STORAGE_CLIENT,
|
||||||
|
operation = "list_buckets",
|
||||||
|
access_key = %MaskedAccessKey(&session_context.principal.user_identity.credentials.access_key),
|
||||||
|
"Protocol storage client request"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = ListBucketsInput::builder().build().map_err(|e| {
|
||||||
|
s3s::S3Error::with_message(s3s::S3ErrorCode::InvalidRequest, format!("Failed to build ListBucketsInput: {}", e))
|
||||||
|
})?;
|
||||||
|
let request = session_list_buckets_request(input, session_context, request_headers, secure_transport);
|
||||||
|
self.fs.list_buckets(request).await.map(|response| response.output)
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error> {
|
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error> {
|
||||||
trace_protocol_request("create_bucket", Some(bucket), None);
|
trace_protocol_request("create_bucket", Some(bucket), None);
|
||||||
|
|
||||||
@@ -872,6 +945,60 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(feature = "webdav")]
|
||||||
|
#[test]
|
||||||
|
fn request_extensions_preserve_authenticated_identity_and_source_ip() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
let claims = HashMap::from([("parent".to_string(), serde_json::json!("alice"))]);
|
||||||
|
let credentials = rustfs_credentials::Credentials {
|
||||||
|
access_key: "service-account".to_string(),
|
||||||
|
secret_key: "secret".to_string(),
|
||||||
|
session_token: "session-token".to_string(),
|
||||||
|
parent_user: "alice".to_string(),
|
||||||
|
groups: Some(vec!["developers".to_string()]),
|
||||||
|
claims: Some(claims.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let source_ip = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10));
|
||||||
|
|
||||||
|
let identity = rustfs_policy::auth::UserIdentity {
|
||||||
|
credentials: credentials.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let principal = rustfs_protocols::common::ProtocolPrincipal::new(std::sync::Arc::new(identity));
|
||||||
|
let session_context = SessionContext::new(principal, rustfs_protocols::Protocol::WebDav, source_ip);
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("user-agent", http::HeaderValue::from_static("webdav-client"));
|
||||||
|
let request = session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, true);
|
||||||
|
let request_info = request.extensions.get::<ReqInfo>().expect("request info should be present");
|
||||||
|
let copied = request_info.cred.as_ref().expect("credentials should be present");
|
||||||
|
let remote_addr = request
|
||||||
|
.extensions
|
||||||
|
.get::<Option<RemoteAddr>>()
|
||||||
|
.and_then(Option::as_ref)
|
||||||
|
.expect("remote address should be present");
|
||||||
|
let client_info = request.extensions.get::<ClientInfo>().expect("client info should be present");
|
||||||
|
|
||||||
|
assert_eq!(copied.access_key, credentials.access_key);
|
||||||
|
assert_eq!(copied.secret_key, credentials.secret_key);
|
||||||
|
assert_eq!(copied.session_token, credentials.session_token);
|
||||||
|
assert_eq!(copied.parent_user, credentials.parent_user);
|
||||||
|
assert_eq!(copied.groups, credentials.groups);
|
||||||
|
assert_eq!(copied.claims, Some(claims));
|
||||||
|
assert_eq!(remote_addr.0.ip(), source_ip);
|
||||||
|
assert_eq!(client_info.real_ip, source_ip);
|
||||||
|
assert_eq!(client_info.forwarded_proto.as_deref(), Some("https"));
|
||||||
|
assert_eq!(request.headers.get("user-agent").expect("user agent"), "webdav-client");
|
||||||
|
|
||||||
|
let insecure_request = session_list_buckets_request(ListBucketsInput::default(), &session_context, &headers, false);
|
||||||
|
let insecure_client_info = insecure_request
|
||||||
|
.extensions
|
||||||
|
.get::<ClientInfo>()
|
||||||
|
.expect("client info should be present");
|
||||||
|
assert_eq!(insecure_client_info.forwarded_proto.as_deref(), Some("http"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_object_uri_encodes_key_segments_without_flattening_slashes() {
|
fn build_object_uri_encodes_key_segments_without_flattening_slashes() {
|
||||||
|
|||||||
Reference in New Issue
Block a user