mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 11:07:35 +01:00
* 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>
186 lines
8.5 KiB
Go
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)
|
|
}
|