From 205337151a2ce0a68ea4a7dcbab89fddd34bd934 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 21 Aug 2026 00:15:33 +0800 Subject: [PATCH] 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 --- crates/e2e_test/src/protocols/webdav_core.rs | 105 ++++++++ crates/protocols/Cargo.toml | 2 +- crates/protocols/src/common/client/s3.rs | 21 +- crates/protocols/src/common/dummy_storage.rs | 49 +++- crates/protocols/src/webdav/driver.rs | 247 ++++++++++++++++--- crates/protocols/src/webdav/server.rs | 52 +++- rustfs/src/protocols/client.rs | 127 ++++++++++ 7 files changed, 555 insertions(+), 48 deletions(-) diff --git a/crates/e2e_test/src/protocols/webdav_core.rs b/crates/e2e_test/src/protocols/webdav_core.rs index 761f9ffe4..3b15a1d43 100644 --- a/crates/e2e_test/src/protocols/webdav_core.rs +++ b/crates/e2e_test/src/protocols/webdav_core.rs @@ -233,6 +233,111 @@ pub async fn test_webdav_core_operations() -> Result<()> { ); 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) info!("Testing WebDAV: GET (download file '{}')", filename); let resp = client diff --git a/crates/protocols/Cargo.toml b/crates/protocols/Cargo.toml index 461302913..62a0c5876 100644 --- a/crates/protocols/Cargo.toml +++ b/crates/protocols/Cargo.toml @@ -106,7 +106,7 @@ swift = [ "dep:base64", "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"] [dependencies] diff --git a/crates/protocols/src/common/client/s3.rs b/crates/protocols/src/common/client/s3.rs index ddfa9c88e..25e4d3d4c 100644 --- a/crates/protocols/src/common/client/s3.rs +++ b/crates/protocols/src/common/client/s3.rs @@ -15,6 +15,9 @@ use async_trait::async_trait; use s3s::dto::*; +#[cfg(feature = "webdav")] +use crate::common::session::SessionContext; + #[async_trait] pub trait StorageBackend: Send + Sync { /// Error type for this storage backend @@ -65,8 +68,24 @@ pub trait StorageBackend: Send + Sync { access_key: &str, secret_key: &str, ) -> Result; - /// List all buckets (requires authentication) + /// List all buckets (requires authentication). async fn list_buckets(&self, access_key: &str, secret_key: &str) -> Result; + /// 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 { + Err(s3s::S3Error::with_message( + s3s::S3ErrorCode::AccessDenied, + "Session-aware bucket listing is not supported", + )) + } /// Create a new bucket async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result; /// Delete a bucket (must be empty) diff --git a/crates/protocols/src/common/dummy_storage.rs b/crates/protocols/src/common/dummy_storage.rs index 3ae80bb89..4df113e78 100644 --- a/crates/protocols/src/common/dummy_storage.rs +++ b/crates/protocols/src/common/dummy_storage.rs @@ -30,6 +30,8 @@ //! SessionContext type in common::session. use crate::common::client::s3::StorageBackend; +#[cfg(feature = "webdav")] +use crate::common::session::SessionContext; use async_trait::async_trait; use bytes::Bytes; use futures_util::stream::{self, StreamExt}; @@ -140,6 +142,8 @@ struct Inner { head_bucket: VecDeque>, list_objects_v2: VecDeque>, list_buckets: VecDeque>, + session_list_buckets: VecDeque>, + last_session_list_context: Option<(http::HeaderMap, bool)>, create_bucket: VecDeque>, delete_bucket: VecDeque>, copy_object: VecDeque>, @@ -193,6 +197,8 @@ impl Inner { head_bucket: VecDeque::new(), list_objects_v2: VecDeque::new(), list_buckets: VecDeque::new(), + session_list_buckets: VecDeque::new(), + last_session_list_context: None, create_bucket: VecDeque::new(), delete_bucket: VecDeque::new(), copy_object: VecDeque::new(), @@ -301,6 +307,31 @@ impl DummyBackend { .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 /// to script SlowDown / AccessDenied sequences against the /// 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 { + async fn list_buckets(&self, _access_key: &str, _secret_key: &str) -> Result { match self.inner.lock().expect("lock").list_buckets.pop_front() { Some(r) => r, 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 { + 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 { match self.inner.lock().expect("lock").create_bucket.pop_front() { Some(r) => r, diff --git a/crates/protocols/src/webdav/driver.rs b/crates/protocols/src/webdav/driver.rs index b98538af2..f1d7f511c 100644 --- a/crates/protocols/src/webdav/driver.rs +++ b/crates/protocols/src/webdav/driver.rs @@ -13,7 +13,7 @@ // limitations under the License. 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 bytes::Bytes; use dav_server::davpath::DavPath; @@ -24,6 +24,7 @@ use futures_util::{FutureExt, StreamExt, stream}; use percent_encoding::percent_decode_str; use rustfs_utils::MaskedAccessKey; use rustfs_utils::path; +use s3s::S3ErrorCode; use s3s::dto::*; use std::fmt::Debug; use std::io::SeekFrom; @@ -457,6 +458,10 @@ where storage: S, /// Session context for authorization session_context: Arc, + /// Policy-safe WebDAV request headers used by IAM conditions. + request_headers: Option, + /// Whether the WebDAV connection uses TLS. + secure_transport: bool, } enum ResolvedPath { @@ -490,6 +495,8 @@ where Self { storage: self.storage.clone(), session_context: self.session_context.clone(), + request_headers: self.request_headers.clone(), + secure_transport: self.secure_transport, } } } @@ -503,9 +510,18 @@ where Self { storage, 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) { ( &self.session_context.principal.user_identity.credentials.access_key, @@ -799,50 +815,41 @@ where /// List all buckets (for root path) async fn list_buckets(&self) -> FsResult> { match authorize_operation(&self.session_context, &S3Action::ListBuckets, "", None).await { - Ok(_) => {} - Err(_e) => { - return Err(FsError::Forbidden); + Ok(()) => { + let (access_key, secret_key) = self.credentials(); + 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 - .list_buckets( - &self.session_context.principal.user_identity.credentials.access_key, - &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); + .list_buckets_for_session(&self.session_context, request_headers, self.secure_transport) + .await; - entries.push(WebDavDirEntry { - name: bucket_name.clone(), - metadata: WebDavMetaData { - size: 0, - modified, - created: modified, - is_dir: true, - etag: None, - content_type: None, - }, - }); - } - } - } - Ok(entries) - } + match result { + Ok(output) => Ok(Self::bucket_entries(output)), Err(e) => { + if matches!(e.code(), S3ErrorCode::AccessDenied) { + return Err(FsError::Forbidden); + } error!( event = EVENT_WEBDAV_BUCKET_LIST_FAILED, component = LOG_COMPONENT_PROTOCOLS, @@ -856,6 +863,35 @@ where } } + fn bucket_entries(output: ListBucketsOutput) -> Vec { + 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 async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> FsResult> { // Authorize the operation @@ -1715,8 +1751,9 @@ where mod tests { use super::WebDavDriver; use crate::common::client::s3::StorageBackend as S3StorageBackend; - use crate::common::gateway::{S3Action, with_test_auth_override}; - use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext}; + use crate::common::dummy_storage::DummyBackend; + 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 bytes::Bytes; use dav_server::davpath::DavPath; @@ -1906,6 +1943,134 @@ mod tests { 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)] struct RecordingStorageState { objects: HashMap<(String, String), Vec>, diff --git a/crates/protocols/src/webdav/server.rs b/crates/protocols/src/webdav/server.rs index 975eb3cb8..fd1f56c09 100644 --- a/crates/protocols/src/webdav/server.rs +++ b/crates/protocols/src/webdav/server.rs @@ -19,6 +19,8 @@ use crate::common::session::{Protocol, ProtocolPrincipal, SessionContext, is_tem use bytes::Bytes; use dav_server::DavHandler; 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 hyper::body::Body as HttpBody; 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. type WebDavBody = Pin + 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 pub struct WebDavServer where @@ -216,7 +232,7 @@ where match timeout(request_timeout, acceptor.accept(stream)).await { Ok(Ok(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!( event = EVENT_WEBDAV_CONNECTION_STATE, component = LOG_COMPONENT_PROTOCOLS, @@ -254,7 +270,7 @@ where } } else { 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!( event = EVENT_WEBDAV_CONNECTION_STATE, component = LOG_COMPONENT_PROTOCOLS, @@ -313,6 +329,7 @@ where io: TokioIo, storage: S, source_ip: IpAddr, + secure_transport: bool, max_body_size: u64, request_timeout: Duration, ) -> Result<(), Box> @@ -321,7 +338,7 @@ where { let service = service_fn(move |req: Request| { 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) @@ -341,6 +358,7 @@ where req: Request, storage: S, source_ip: IpAddr, + secure_transport: bool, max_body_size: u64, request_timeout: Duration, ) -> Result, Infallible> { @@ -398,7 +416,8 @@ where }; // 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 let dav_handler = DavHandler::builder() @@ -883,6 +902,30 @@ mod tests { .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 /// Content-Length, so the limit has to hold on the bytes actually read. #[tokio::test] @@ -959,6 +1002,7 @@ mod tests { TokioIo::new(server), StubStorage, TEST_IP, + false, 1024, Duration::from_secs(30), )); diff --git a/rustfs/src/protocols/client.rs b/rustfs/src/protocols/client.rs index 192b6f615..da8139776 100644 --- a/rustfs/src/protocols/client.rs +++ b/rustfs/src/protocols/client.rs @@ -13,10 +13,16 @@ // limitations under the License. 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 http::{HeaderMap, Method}; use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; use rustfs_credentials; +#[cfg(feature = "webdav")] +use rustfs_protocols::common::SessionContext; +#[cfg(feature = "webdav")] +use rustfs_trusted_proxies::ClientInfo; use rustfs_utils::MaskedAccessKey; use s3s::dto::*; 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 { + 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 { let mut uri = format!("/{}", encode_path_segment(bucket)); 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 { + 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 { trace_protocol_request("create_bucket", Some(bucket), None); @@ -872,6 +945,60 @@ impl rustfs_protocols::common::client::s3::StorageBackend for ProtocolStorageCli #[cfg(test)] mod tests { 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::().expect("request info should be present"); + let copied = request_info.cred.as_ref().expect("credentials should be present"); + let remote_addr = request + .extensions + .get::>() + .and_then(Option::as_ref) + .expect("remote address should be present"); + let client_info = request.extensions.get::().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::() + .expect("client info should be present"); + assert_eq!(insecure_client_info.forwarded_proto.as_deref(), Some("http")); + } #[test] fn build_object_uri_encodes_key_segments_without_flattening_slashes() {