Files
seaweedfs/weed/s3api/s3api_object_anonymous_read.go
T
Chris LuandClaude Opus 5.5 b0c6297041 s3api: per-bucket Block Public Access (BlockPublicAcls, IgnorePublicAcls) (#12381)
* s3api: store and serve a bucket's PublicAccessBlock configuration

PutPublicAccessBlock, GetPublicAccessBlock and DeletePublicAccessBlock
answered NotImplemented. Store the configuration in the bucket entry's
extended attributes next to the ownership controls, carry it in the
cached BucketConfig, and answer Get on an unset bucket with
NoSuchPublicAccessBlockConfiguration like S3 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: refuse public ACLs when a bucket blocks them

With BlockPublicAcls set, a PutObject, CopyObject, CreateMultipartUpload,
POST Object, PutObjectAcl or PutBucketAcl whose canned ACL or grants name
the AllUsers or AuthenticatedUsers group is refused with AccessDenied.
Private and account grants keep working, so clients that send
x-amz-acl: private on every upload are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: ignore public ACL grants for anonymous reads under IgnorePublicAcls

A bucket with IgnorePublicAcls no longer lets an AllUsers grant on the
bucket or on an object answer an anonymous read or listing. Bucket
policies and identity permissions are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject PublicAccessBlock bodies with unknown elements

A misspelled element such as BlockPublicAcl was ignored, so the request
succeeded and stored the flag as false. Answer MalformedXML instead, and
store the configuration without the request's namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: fail closed when a bucket's public access block cannot be read

A stored PublicAccessBlock that does not parse now blocks and ignores
public ACLs instead of acting as if none were set. PutBucketAcl and POST
Object return the bucket lookup error rather than skipping the
BlockPublicAcls check when the bucket config cannot be loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test BlockPublicAcls on copy, multipart initiate and a PUT-then-GET

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: keep stored public access block bytes on unrelated bucket updates

An unreadable stored configuration was replaced with the in-memory
all-true fallback whenever another bucket setting was updated, losing
the original value. Write the key only when the update changes the
public access block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:52:20 -08:00

186 lines
8.5 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,
// and IgnorePublicAcls leaves an object ACL nothing it could grant here.
if !config.PublicAccessBlock.ignoresPublicAcls() &&
(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 := s3_constants.NormalizeObjectKey(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. The denial is
// refined like Auth's, so a malformed bucket name answers InvalidBucketName.
func (s3a *S3ApiServer) writeAnonymousAwareNotFound(w http.ResponseWriter, r *http.Request, bucket string, code s3err.ErrorCode) {
if isAnonymousObjectRead(r) && s3a.authorizeAnonymousBucketList(r, bucket) != s3err.ErrNone {
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, s3err.ErrAccessDenied))
return
}
s3err.WriteErrorResponse(w, r, code)
}