mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 23:09:33 +01:00
* s3api: deny signed reads that name a version file by its physical path A stored version lives at <key>.versions/v_<id>, and that path is also a valid object key. Anonymous reads already refuse it; signed GET, HEAD, copy sources and object subresources did not, so a caller without s3:GetObjectVersion, or under a Deny on the logical key, could read any version through it. Apply the same check in the shared entry recheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: recheck a version file key before answering a conditional read A conditional GET or HEAD answered 304 or 412 with the ETag before the handler's entry recheck, so the physical version path still confirmed a version's ETag to signed callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: look up version file keys in the lookup-based policy recheck Retention, legal hold, tagging writes and deletes only fetched the entry for the recheck when the bucket had a policy, so without one they still acted on a version file named by its physical path. Fetch it whenever the key has the version file shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: refuse a directory-marker delete that names a version file A trailing slash kept "<key>.versions/v_<id>/" out of the version file shape check, and the lookup recheck misses the entry for that form, so a DELETE reached deleteDirectoryMarker and removed a chunkless version or delete marker without s3:DeleteObjectVersion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
9.0 KiB
Go
195 lines
9.0 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
|
|
}
|
|
|
|
// isVersionFileKey reports whether object names a stored version's physical
|
|
// filer path rather than a logical key: accepting it would bypass
|
|
// GetObjectVersion policies and key-scoped denies on the logical key. Ordinary
|
|
// user keys containing ".versions" without version metadata are unaffected.
|
|
func (s3a *S3ApiServer) isVersionFileKey(object string, extended map[string][]byte) bool {
|
|
version := string(extended[s3_constants.ExtVersionIdKey])
|
|
return version != "" && s3a.hasVersionFileShape(object) && path.Base(s3_constants.NormalizeObjectKey(object)) == s3a.getVersionFileName(version)
|
|
}
|
|
|
|
func (s3a *S3ApiServer) hasVersionFileShape(object string) bool {
|
|
key := strings.TrimSuffix(s3_constants.NormalizeObjectKey(object), "/")
|
|
return strings.HasSuffix(path.Dir(key), s3_constants.VersionsFolder) && strings.HasPrefix(path.Base(key), s3a.getVersionFileName(""))
|
|
}
|
|
|
|
// 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 {
|
|
if s3a.isVersionFileKey(object, extended) {
|
|
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)
|
|
}
|