Files
seaweedfs/weed/s3api/s3api_object_anonymous_read.go
T
Chris LuandDevin 0de6347b11 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>
2026-10-20 12:52:58 -07:00

182 lines
8.2 KiB
Go

package s3api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"path"
"strings"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
type anonymousObjectReadContextKey struct{}
// isAnonymousObjectRead identifies requests deferred by AuthWithPublicRead to
// the selected entry's authorization check. Clients cannot set this marker.
func isAnonymousObjectRead(r *http.Request) bool {
marked, _ := r.Context().Value(anonymousObjectReadContextKey{}).(bool)
return marked
}
// deferAnonymousObjectRead only grants entry-dependent authorization to raw
// GET/HEAD reads. Subresources, writes and requests carrying credentials keep
// their normal authentication path; no-auth development mode is unchanged.
func (s3a *S3ApiServer) deferAnonymousObjectRead(r *http.Request, action Action, bucket, object string) (*http.Request, bool) {
if s3a.iam == nil || !s3a.iam.isEnabled() || action != s3_constants.ACTION_READ ||
getRequestAuthType(r) != authTypeAnonymous || object == "" || object == "/" ||
(r.Method != http.MethodGet && r.Method != http.MethodHead) || isSOSAPIObject(strings.TrimPrefix(object, "/")) {
return r, false
}
resolved := ResolveS3Action(r, string(action), bucket, object)
if r.URL.Query().Has("uploads") || (resolved != s3_constants.S3_ACTION_GET_OBJECT && resolved != s3_constants.S3_ACTION_GET_OBJECT_VERSION) {
return r, false
}
// A session token without a signature names no session; drop it so it
// cannot influence identity or policy evaluation downstream.
r.Header.Del("X-Amz-Security-Token")
if q := r.URL.Query(); q.Has("X-Amz-Security-Token") {
q.Del("X-Amz-Security-Token")
r.URL.RawQuery = q.Encode()
}
// Reuse authentication's internal-header sanitization. A public ACL does
// not require an anonymous identity; a configured one still contributes
// its permissions and explicit identity-policy denies.
identity, _, _ := s3a.iam.authenticateRequestInternal(r)
ctx := recordIdentityInContext(r, identity)
return r.WithContext(context.WithValue(ctx, anonymousObjectReadContextKey{}, true)), true
}
// authorizeAnonymousObjectRead evaluates policies and ACLs against the same
// entry used for the response, including conditional and versioned reads.
// Explicit policy denies take precedence over every public-access grant.
func (s3a *S3ApiServer) authorizeAnonymousObjectRead(r *http.Request, bucket, object string, extended map[string][]byte) s3err.ErrorCode {
// A version's physical filer path is not another S3 key: accepting it would
// bypass GetObjectVersion policies on the logical key. Ordinary user keys
// containing ".versions" without internal version metadata are unaffected.
key := s3_constants.NormalizeObjectKey(object)
version := string(extended[s3_constants.ExtVersionIdKey])
if version != "" && strings.HasSuffix(path.Dir(key), s3_constants.VersionsFolder) && path.Base(key) == s3a.getVersionFileName(version) {
return s3err.ErrAccessDenied
}
if string(extended[s3_constants.ExtDeleteMarkerKey]) == "true" {
return s3err.ErrAccessDenied
}
// Loading config also synchronizes a cold bucket policy into the engine.
config, code := s3a.getBucketConfig(bucket)
if code != s3err.ErrNone {
return code
}
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
code, policyAllowed := s3a.checkPolicyWithEntry(r, bucket, object, string(s3_constants.ACTION_READ), buildPrincipalARN(identity, r), extended)
if code != s3err.ErrNone {
return code
}
if s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(r, identity, s3_constants.ACTION_READ, bucket, object) {
return s3err.ErrAccessDenied
}
if policyAllowed {
return s3err.ErrNone
}
if identity != nil && s3a.iam.VerifyActionPermission(r, identity, s3_constants.ACTION_READ, bucket, object) == s3err.ErrNone {
return s3err.ErrNone
}
// Match the upload/ACL path: legacy buckets without recorded ownership
// controls still accept ACLs. An enforced control explicitly disables them.
if config.Ownership == "" || s3_constants.EffectiveOwnership(config.Ownership) != s3_constants.OwnershipBucketOwnerEnforced {
if data, exists := extended[s3_constants.ExtAmzAclKey]; exists {
// An explicitly stored ACL replaces the legacy bucket-ACL fallback.
// Empty, malformed and non-public ACLs must not grant anonymous read.
var grants []*s3.Grant
if json.Unmarshal(data, &grants) == nil {
for _, grant := range grants {
if grant != nil && grant.Grantee != nil && grant.Grantee.Type != nil && *grant.Grantee.Type == "Group" &&
grant.Grantee.URI != nil && *grant.Grantee.URI == s3_constants.GranteeGroupAllUsers && grant.Permission != nil &&
(*grant.Permission == s3_constants.PermissionRead || *grant.Permission == s3_constants.PermissionFullControl) {
return s3err.ErrNone
}
}
}
return s3err.ErrAccessDenied
}
}
// Preserve SeaweedFS's bucket-public-read behavior for legacy objects that
// have no stored object ACL; BucketOwnerEnforced ignores object grants.
if config.IsPublicRead {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
}
// authorizeAnonymousBucketList reports whether an anonymous caller may learn
// which keys exist in the bucket: bucket listing via policy or identity, or a
// public-read bucket. Anything else masks the miss as AccessDenied.
func (s3a *S3ApiServer) authorizeAnonymousBucketList(r *http.Request, bucket string) s3err.ErrorCode {
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
object := mux.Vars(r)["object"]
// The caller's query parameters would otherwise retarget these checks
// (?policy resolves to GetBucketPolicy) or satisfy a prefix condition on an
// unrelated subtree, so each runs on a request with a fixed shape: a
// ListBucket scoped to the probed key, and a plain read of the key itself.
// A caller that could learn the key's existence either way — by listing or
// by getting it — gets the real miss.
listReq := r.Clone(r.Context())
listReq.URL = &url.URL{Path: "/" + bucket, RawQuery: url.Values{"prefix": {object}}.Encode()}
// versionId must survive: a read of a specific version resolves to
// s3:GetObjectVersion, and dropping it would let a GetObject grant answer
// for versions the caller may not read.
readQuery := url.Values{}
if v := r.URL.Query().Get("versionId"); v != "" {
readQuery.Set("versionId", v)
}
readReq := r.Clone(r.Context())
readReq.URL = &url.URL{Path: "/" + bucket + "/" + object, RawQuery: readQuery.Encode()}
principal := buildPrincipalARN(identity, r)
policyAllowed := false
code, allowed := s3a.checkPolicyWithEntry(listReq, bucket, "", string(s3_constants.ACTION_LIST), principal, nil)
if code != s3err.ErrNone {
return code
}
policyAllowed = allowed
if !policyAllowed {
code, allowed = s3a.checkPolicyWithEntry(readReq, bucket, object, string(s3_constants.ACTION_READ), principal, nil)
if code != s3err.ErrNone {
return code
}
policyAllowed = allowed
}
if s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(listReq, identity, s3_constants.ACTION_LIST, bucket, "") ||
s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(readReq, identity, s3_constants.ACTION_READ, bucket, object) {
return s3err.ErrAccessDenied
}
if policyAllowed {
return s3err.ErrNone
}
if identity != nil &&
(s3a.iam.VerifyActionPermission(listReq, identity, s3_constants.ACTION_LIST, bucket, "") == s3err.ErrNone ||
s3a.iam.VerifyActionPermission(readReq, identity, s3_constants.ACTION_READ, bucket, object) == s3err.ErrNone) {
return s3err.ErrNone
}
if config, code := s3a.getBucketConfig(bucket); code == s3err.ErrNone && config.IsPublicRead {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
}
// writeAnonymousAwareNotFound keeps missing-bucket and missing-key misses
// indistinguishable from denied reads for anonymous callers: a 404 only
// reaches the wire when the bucket is anonymously listable.
func (s3a *S3ApiServer) writeAnonymousAwareNotFound(w http.ResponseWriter, r *http.Request, bucket string, code s3err.ErrorCode) {
if isAnonymousObjectRead(r) && s3a.authorizeAnonymousBucketList(r, bucket) != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
s3err.WriteErrorResponse(w, r, code)
}