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:
GatewayJ
2026-08-21 00:15:33 +08:00
committed by GitHub
parent 105b6fbfde
commit 205337151a
7 changed files with 555 additions and 48 deletions
@@ -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
+1 -1
View File
@@ -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]
+20 -1
View File
@@ -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<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>;
/// 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
async fn create_bucket(&self, bucket: &str, access_key: &str, secret_key: &str) -> Result<CreateBucketOutput, Self::Error>;
/// Delete a bucket (must be empty)
+48 -1
View File
@@ -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<Result<HeadBucketOutput, DummyError>>,
list_objects_v2: VecDeque<Result<ListObjectsV2Output, 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>>,
delete_bucket: VecDeque<Result<DeleteBucketOutput, DummyError>>,
copy_object: VecDeque<Result<CopyObjectOutput, DummyError>>,
@@ -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<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() {
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<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> {
match self.inner.lock().expect("lock").create_bucket.pop_front() {
Some(r) => r,
+206 -41
View File
@@ -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<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 {
@@ -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<Vec<WebDavDirEntry>> {
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<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
async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> FsResult<Vec<WebDavDirEntry>> {
// 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<u8>>,
+48 -4
View File
@@ -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<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
pub struct WebDavServer<S>
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<I>,
storage: S,
source_ip: IpAddr,
secure_transport: bool,
max_body_size: u64,
request_timeout: Duration,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
@@ -321,7 +338,7 @@ where
{
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
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<hyper::body::Incoming>,
storage: S,
source_ip: IpAddr,
secure_transport: bool,
max_body_size: u64,
request_timeout: Duration,
) -> Result<Response<WebDavBody>, 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),
));