s3: evaluate tag conditions on copy-source and tagging subresources (#11771)

* s3: evaluate tag conditions on copy-source and tagging subresources

Policy evaluation ran without the object entry on several paths, so a
Deny conditioned on s3:ExistingObjectTag never matched:

- CopyObject, UploadPartCopy and RenameObject checked the source key
  before its entry was fetched; repeat the check on a synthesized GET
  once the entry resolves.
- GetObjectAttributes and Get/Put/DeleteObjectTagging never supplied
  the entry; recheck with the resolved entry.

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

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

* s3: fail closed on tag-policy lookups and honor tag-conditioned grants

- recheckTagConditionedPolicyWithLookup returned success when the entry
  lookup errored, letting a tag-gated Deny slip past a transient miss;
  only ErrNotFound still falls through to the handler's own NoSuchKey.
- A read or delete denied in the tagless first phase now gets one more
  evaluation with the resolved entry's tags in hand, so an
  s3:ExistingObjectTag-conditioned Allow can still grant a copy-source
  read or rename. The deferred recheck requires an explicit Allow;
  indeterminate results keep the original verdict, and the existence of
  the key never leaks past a deny.
- RenameObject now applies the same tag-aware recheck to the source
  removal, so a tag-gated Deny on s3:DeleteObject blocks a rename the
  way it blocks a direct DELETE.
- GetObjectAttributes evaluates the object-entry policy recheck before
  conditional headers, so a 304/412 response can no longer bypass a
  tag-based Deny.

* s3: honor identity denies in the tag-aware source recheck

The deferred recheck re-evaluates only the bucket policy, so a bucket
Allow could lift an explicit Deny in the caller's attached or session
policies on a copy/rename source. Apply the same applicable-policy Deny
check the first phase uses.

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

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

---------

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-16 19:50:36 -07:00
1 parent 446e6f3444
commit 2d06e2d6c1
8 files changed
+301 -22

No files matched your search

+74
View File
@@ -28,6 +28,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
@@ -675,6 +676,79 @@ func (s3a *S3ApiServer) recheckPolicyWithObjectEntry(r *http.Request, bucket, ob
return errCode
}
// recheckCopySourcePolicy repeats the source object's policy check once its
// entry is fetched, so conditions on the object's existing tags apply to a
// CopyObject / UploadPartCopy / RenameObject source the same way they do to a
// plain GET. The synthesized GET keeps the destination's request shape (PUT,
// partNumber, ...) from resolving a different action.
func (s3a *S3ApiServer) recheckCopySourcePolicy(r *http.Request, bucket, object, versionId string, objectEntry map[string][]byte, handlerName string) s3err.ErrorCode {
keyReq := objectKeyPolicyRequest(r, http.MethodGet, bucket, object, versionId)
return s3a.recheckPolicyWithObjectEntry(keyReq, bucket, object, string(ACTION_READ), objectEntry, handlerName)
}
// recheckTagConditionedPolicyWithLookup repeats policy evaluation with the
// object's entry in hand so s3:ExistingObjectTag conditions apply to tagging
// writes that did not otherwise fetch the entry. It only does the extra
// lookup when the bucket has a policy; entry misses return ErrNone because
// the handler reports its own NoSuchKey.
func (s3a *S3ApiServer) recheckTagConditionedPolicyWithLookup(r *http.Request, bucket, object, action, handlerName string) s3err.ErrorCode {
if s3a.policyEngine == nil || !s3a.policyEngine.HasPolicyForBucket(bucket) {
return s3err.ErrNone
}
target := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object))
dir, name := target.DirAndName()
entry, err := s3a.getEntry(dir, name)
if err != nil {
// The tag conditions cannot be evaluated without the entry; failing
// open would let a tag-gated Deny slip through a transient miss.
if errors.Is(err, filer_pb.ErrNotFound) {
return s3err.ErrNone
}
glog.Errorf("%s: recheck lookup for %s/%s failed: %v", handlerName, bucket, object, err)
return s3err.ErrInternalError
}
if entry == nil {
return s3err.ErrNone
}
return s3a.recheckPolicyWithObjectEntry(r, bucket, object, action, entry.Extended, handlerName)
}
// recheckPolicyForKeyWithLookup re-decides a first-phase deny on one key for a
// synthesized method, resolving the entry the way the read path does (versions
// included) so s3:ExistingObjectTag conditions see the object's real tags.
// Unlike the deny-side rechecks, an indeterminate result does not grant: the
// caller reports the original verdict either way, so only a statement that
// matches with tags in hand can turn a deny into an allow, and a missed lookup
// never widens access or reveals whether the key exists.
func (s3a *S3ApiServer) recheckPolicyForKeyWithLookup(r *http.Request, method, bucket, object, versionId, action, handlerName string) s3err.ErrorCode {
if s3a.policyEngine == nil || !s3a.policyEngine.HasPolicyForBucket(bucket) {
return s3err.ErrAccessDenied
}
versioningState, err := s3a.getVersioningState(bucket)
if err != nil {
glog.Errorf("%s: recheck versioning state for %s: %v", handlerName, bucket, err)
return s3err.ErrAccessDenied
}
entry, err := s3a.resolveCopySourceEntry(bucket, object, versionId, versioningState)
entry = prefixObjectSource(entry)
if err != nil || entry == nil {
return s3err.ErrAccessDenied
}
keyReq := objectKeyPolicyRequest(r, method, bucket, object, versionId)
errCode, checked := s3a.checkPolicyWithEntry(keyReq, bucket, object, action, "", entry.Extended)
if errCode != s3err.ErrNone || !checked {
return s3err.ErrAccessDenied
}
// A bucket-policy Allow never overrides an explicit Deny in the caller's
// own attached or session policies — the first phase applied that veto,
// so the tag-aware recheck must too.
identity, _ := GetIdentityFromContext(r).(*Identity)
if s3a.iam != nil && s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(keyReq, identity, Action(action), bucket, object) {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
}
// classifyDomainNames classifies domains into path-style and virtual-host style domains.
// A domain is considered path-style if:
// 1. It contains a dot (has subdomains)