s3: mask anonymous misses behind AccessDenied unless the bucket is listable (#11857)

* s3: mask anonymous misses behind AccessDenied unless the bucket is listable

Deferred anonymous GET/HEAD authorization only runs once an entry is
selected, so misses returned 404 while existing keys returned 403 — an
unauthenticated existence oracle for bucket names and object keys.

Route not-found responses in Get/HeadObjectHandler through a check that
only discloses 404 when anonymous listing is allowed (policy, identity or
a public-read bucket); otherwise the miss is indistinguishable from a
denied read, matching AWS and 4.48 behavior.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: mask missing-directory misses for anonymous reads too

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: check a fixed request shape when masking anonymous misses

The miss check passed the caller's request through, so query parameters
could retarget the resolved action (?policy) or satisfy an s3:prefix
condition on an unrelated subtree. Each check now runs on a request with a
fixed shape: a ListBucket scoped to the probed key and a plain read of the
key, so the miss only shows when the caller could learn the same fact
directly.

* s3: mask conditional-header misses for anonymous reads

An If-Match conditional on a missing key wrote NoSuchKey directly from
processConditionalHeaders, before the handlers reached the masked miss
path, so anonymous callers could still distinguish a missing key from a
denied read.

* s3: keep the versionId when masking anonymous misses

The read leg must evaluate the action the caller's request resolves to:
a versioned miss is s3:GetObjectVersion, and dropping versionId would let a
plain GetObject grant disclose versions the caller may not read.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Chris LuandDevin authored and GitHub committed 2026-10-20 12:52:58 -07:00
1 parent 6936bbdc3e
commit 0de6347b11
4 files changed
+116 -22

No files matched your search

@@ -11,6 +11,7 @@ import (
"github.com/gorilla/mux"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/require"
)
@@ -228,6 +229,22 @@ func TestObjectLockActionsRoundTrip(t *testing.T) {
}
}
// Anonymous misses must not disclose bucket or key existence: 404 is only
// served when anonymous listing is allowed.
func TestAnonymousBucketListAuthorization(t *testing.T) {
s3a := &S3ApiServer{iam: &IdentityAccessManagement{}, bucketConfigCache: NewBucketConfigCache(time.Minute)}
s3a.bucketConfigCache.Set("open", &BucketConfig{Name: "open", IsPublicRead: true})
s3a.bucketConfigCache.Set("private", &BucketConfig{Name: "private"})
req := httptest.NewRequest(http.MethodGet, "http://localhost:8333/open/key", nil)
if got := s3a.authorizeAnonymousBucketList(req, "open"); got != s3err.ErrNone {
t.Errorf("listable bucket: %v, want ErrNone", got)
}
if got := s3a.authorizeAnonymousBucketList(req, "private"); got != s3err.ErrAccessDenied {
t.Errorf("private bucket: %v, want AccessDenied", got)
}
}
// Internal Seaweed-X-Amz-* headers carry server-validated object lock values
// into entry metadata; client-supplied ones must be dropped on entry or the
// dedicated-permission check on x-amz-object-lock-* is bypassed.