Files
rustfs/crates/e2e_test/src/anonymous_access_test.rs
T
Zhengchao AnandGitHub 92f83bfe15 Merge commit from fork
* fix(policy): quantify negated string conditions per value

ForAllValues:/ForAnyValue: negated string operators computed the positive
quantified match and then negated the aggregate. That yields NOT(all match)
and NOT(any match), which is the semantics of the *other* quantifier, so
ForAllValues:StringNotEquals and ForAnyValue:StringNotEquals were exactly
transposed. The same applied to StringNotEqualsIgnoreCase, StringNotLike,
ArnNotEquals and ArnNotLike.

Introduce an explicit Quantifier and push negation into the per-value
predicate for the qualified forms, so ForAllValues requires every request
value to satisfy the operator and ForAnyValue requires at least one.
Unqualified operators keep negating the aggregate, preserving AWS
single-valued-key semantics. Absent keys now follow AWS: ForAllValues is
vacuously satisfied, ForAnyValue is not.

A request value set that is fully contained in or fully disjoint from the
policy set cannot distinguish the two quantifiers, which is why the existing
cases missed this; the new tests use partially overlapping sets.

* fix(auth): keep request headers out of server-derived condition keys

get_condition_values folded every remaining request header into the policy
condition map. HeaderMap lowercases header names, and the server-derived keys
userid, username, principaltype, versionid and signatureversion are lowercase
too, so a header of the same name collided with them. The collision branch used
extend(), and non-quantified string operators match if ANY value in the vector
matches, so sending `userid: admin` was enough to satisfy a condition on
aws:userid. The jwt:/ldap: claim keys were reachable the same way whenever the
credential carried no such claim.

groups was worse than an append: the header loop ran before the cred.groups
block, which is gated on !args.contains_key("groups"), so a `groups:` header
both injected a value and suppressed the credential's real group list.

Resolve claims and group membership before merging headers, then skip any header
naming a key the server already derived or a well-known identity/context key.
The reserved set comes from KeyName so it tracks the key registry; s3:x-amz-*
keys stay mergeable because they mirror request headers by design.

* fix(access): gate the ListBucketVersions fallback on public-access checks

An anonymous request for ListObjectVersions that the bucket policy does not
grant directly falls back to re-checking the grant as s3:ListBucket. That
fallback returned Ok(()) straight away, skipping the two gates the direct grant
passes through: deny_anonymous_table_data_plane_if_needed and the
RestrictPublicBuckets check on the bucket's public-access block.

So a bucket whose policy allows anonymous s3:ListBucket kept serving anonymous
version listings after an operator enabled RestrictPublicBuckets, even though
the equivalent GetObject was correctly denied.

Fold the fallback into policy_allowed so both routes reach the same gates.

* fix(ftps): authorize MKD against the CreateBucket boundary

FTPS MKD creates a bucket but ran no authorization check, so any principal that
could open an FTPS session could create buckets regardless of policy. Every
other operation in this driver authorizes first — LIST, RETR, STOR, DELE and
RMD all call authorize_operation — and the WebDAV gateway checks
S3Action::CreateBucket on the equivalent path.

Add the matching check so MKD clears the same boundary as an S3 CreateBucket.
2026-07-26 06:14:01 +08:00

251 lines
8.1 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Regression tests for Issue #2036
//! Verifies that anonymous access works correctly with bucket policies
//! when PublicAccessBlock configuration is missing or explicitly set.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
use serial_test::serial;
use tracing::info;
async fn setup_public_bucket(
env: &RustFSTestEnvironment,
bucket_name: &str,
) -> Result<aws_sdk_s3::Client, Box<dyn std::error::Error + Send + Sync>> {
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket_name).send().await?;
let policy_json = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAnonymousGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{}/*", bucket_name)]
}
]
})
.to_string();
admin_client
.put_bucket_policy()
.bucket(bucket_name)
.policy(&policy_json)
.send()
.await?;
admin_client
.put_object()
.bucket(bucket_name)
.key("test.txt")
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello anonymous"))
.send()
.await?;
Ok(admin_client)
}
async fn anonymous_get_object(
env: &RustFSTestEnvironment,
bucket_name: &str,
key: &str,
) -> Result<reqwest::Response, reqwest::Error> {
let url = format!("{}/{}/{}", env.url, bucket_name, key);
local_http_client().get(&url).send().await
}
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
/// and no PublicAccessBlock configuration exists (ConfigNotFound).
#[tokio::test]
#[serial]
async fn test_anonymous_access_allowed_when_public_access_block_missing() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
info!("Starting test: anonymous access with missing PublicAccessBlock config...");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket_name = "anon-test-no-pab";
let admin_client = setup_public_bucket(&env, bucket_name).await?;
let _ = admin_client.delete_public_access_block().bucket(bucket_name).send().await;
let resp = anonymous_get_object(&env, bucket_name, "test.txt").await?;
assert_eq!(
resp.status().as_u16(),
200,
"Anonymous GetObject should succeed when no PublicAccessBlock config exists"
);
info!("Test passed: anonymous access allowed with missing PublicAccessBlock config");
Ok(())
}
/// Anonymous GetObject should be denied when RestrictPublicBuckets is true.
#[tokio::test]
#[serial]
async fn test_anonymous_access_denied_when_restrict_public_buckets_enabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting test: anonymous access denied with RestrictPublicBuckets=true...");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket_name = "anon-test-restrict";
let admin_client = setup_public_bucket(&env, bucket_name).await?;
admin_client
.put_public_access_block()
.bucket(bucket_name)
.public_access_block_configuration(
PublicAccessBlockConfiguration::builder()
.restrict_public_buckets(true)
.build(),
)
.send()
.await?;
let resp = anonymous_get_object(&env, bucket_name, "test.txt").await?;
assert_eq!(
resp.status().as_u16(),
403,
"Anonymous GetObject should be denied when RestrictPublicBuckets is true"
);
info!("Test passed: anonymous access denied with RestrictPublicBuckets=true");
Ok(())
}
/// Anonymous GetObject should succeed when PublicAccessBlock exists but
/// RestrictPublicBuckets is explicitly false.
#[tokio::test]
#[serial]
async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting test: anonymous access allowed with RestrictPublicBuckets=false...");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket_name = "anon-test-allow";
let admin_client = setup_public_bucket(&env, bucket_name).await?;
admin_client
.put_public_access_block()
.bucket(bucket_name)
.public_access_block_configuration(
PublicAccessBlockConfiguration::builder()
.restrict_public_buckets(false)
.build(),
)
.send()
.await?;
let resp = anonymous_get_object(&env, bucket_name, "test.txt").await?;
assert_eq!(
resp.status().as_u16(),
200,
"Anonymous GetObject should succeed when RestrictPublicBuckets is false"
);
info!("Test passed: anonymous access allowed with RestrictPublicBuckets=false");
Ok(())
}
/// A policy granting anonymous `s3:ListBucket` also permits ListObjectVersions.
/// That grant must still be subject to RestrictPublicBuckets: the versions listing
/// reaches authorization through a fallback branch, and that branch has to apply the
/// same public-access gate as a direct grant.
#[tokio::test]
#[serial]
async fn test_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
info!("Starting test: anonymous ListObjectVersions denied with RestrictPublicBuckets=true...");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket_name = "anon-test-restrict-versions";
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket_name).send().await?;
let policy_json = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAnonymousListBucket",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:ListBucket"],
"Resource": [format!("arn:aws:s3:::{}", bucket_name)]
}
]
})
.to_string();
admin_client
.put_bucket_policy()
.bucket(bucket_name)
.policy(&policy_json)
.send()
.await?;
admin_client
.put_object()
.bucket(bucket_name)
.key("test.txt")
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello anonymous"))
.send()
.await?;
// Without the public-access block the fallback grant is expected to work.
let versions_url = format!("{}/{}?versions=", env.url, bucket_name);
let resp = local_http_client().get(&versions_url).send().await?;
assert_eq!(
resp.status().as_u16(),
200,
"Anonymous ListObjectVersions should succeed via the s3:ListBucket grant"
);
admin_client
.put_public_access_block()
.bucket(bucket_name)
.public_access_block_configuration(
PublicAccessBlockConfiguration::builder()
.restrict_public_buckets(true)
.build(),
)
.send()
.await?;
let resp = local_http_client().get(&versions_url).send().await?;
assert_eq!(
resp.status().as_u16(),
403,
"Anonymous ListObjectVersions must be denied when RestrictPublicBuckets is true"
);
info!("Test passed: anonymous ListObjectVersions denied with RestrictPublicBuckets=true");
Ok(())
}