s3api: verify source permission on CopyObject and UploadPartCopy (#9555)

* s3api: verify source permission on CopyObject and UploadPartCopy

The Auth middleware only authorized the destination because routes key on
the request URL. The source from X-Amz-Copy-Source was never evaluated,
so an STS session token scoped to one prefix could copy from any other
prefix in the same bucket.

Add AuthorizeCopySource on IdentityAccessManagement to run the full
bucket-policy + IAM/identity flow against the source, using a synthetic
GetObject request so action resolution lands on s3:GetObject (or
s3:GetObjectVersion when a source versionId is supplied). Both
CopyObjectHandler and CopyObjectPartHandler now invoke it before reading
the source.

* s3api: preserve presigned-URL session token on copy-source check

Presigned CopyObject / UploadPartCopy requests carry the STS session
token in the query string (X-Amz-Security-Token), not in a header.
Rebuilding the synthetic source URL from scratch dropped that token, so
the source authorization would fall through to non-STS paths and miss
session policy enforcement. Forward X-Amz-Security-Token from the
original query (alongside versionId), still excluding unrelated params
like uploadId/partNumber that would steer ResolveS3Action away from
s3:GetObject.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-05-18 21:35:53 -07:00
1 parent 7c252e1f16
commit c61d227613
3 files changed
+405

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
package s3api
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// newCopyRequest builds a request that mimics what a CopyObject caller sends:
// a PUT to the destination bucket/object with X-Amz-Copy-Source pointing at the
// source. The destination is what the Auth middleware would have authorized.
func newCopyRequest(t *testing.T, dstBucket, dstObject, copySource string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodPut, "http://s3.local/"+dstBucket+"/"+dstObject, nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Copy-Source", copySource)
return req
}
// TestAuthorizeCopySource_AuthDisabled verifies the source check is a no-op when
// the IAM is not enabled (no identities configured).
func TestAuthorizeCopySource_AuthDisabled(t *testing.T) {
iam := &IdentityAccessManagement{}
require.False(t, iam.isEnabled())
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
errCode := iam.AuthorizeCopySource(req, nil, "src-bucket", "src-obj", "")
assert.Equal(t, s3err.ErrNone, errCode)
}
// TestAuthorizeCopySource_NilIdentityDenies verifies that with auth enabled we
// fail closed if no identity is available.
func TestAuthorizeCopySource_NilIdentityDenies(t *testing.T) {
iam := &IdentityAccessManagement{}
iam.identities = []*Identity{{Name: "anyone"}}
iam.isAuthEnabled = true
require.True(t, iam.isEnabled())
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
errCode := iam.AuthorizeCopySource(req, nil, "src-bucket", "src-obj", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
}
// TestAuthorizeCopySource_AdminAllowed verifies admin identities bypass the
// source check.
func TestAuthorizeCopySource_AdminAllowed(t *testing.T) {
iam := &IdentityAccessManagement{}
iam.identities = []*Identity{{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}}
iam.isAuthEnabled = true
admin := &Identity{Name: "admin", Account: &AccountAdmin, Actions: []Action{s3_constants.ACTION_ADMIN}}
req := newCopyRequest(t, "dst-bucket", "dst-obj", "/src-bucket/src-obj")
errCode := iam.AuthorizeCopySource(req, admin, "src-bucket", "src-obj", "")
assert.Equal(t, s3err.ErrNone, errCode)
}
// TestAuthorizeCopySource_PrefixScopedIdentity checks that an identity scoped
// to prefix-a/* is denied when copying from prefix-b/*, even when its
// destination (prefix-a/*) write permission is fine.
func TestAuthorizeCopySource_PrefixScopedIdentity(t *testing.T) {
iam := &IdentityAccessManagement{}
iam.isAuthEnabled = true
scoped := &Identity{
Name: "scoped",
Account: &AccountAdmin,
Actions: []Action{
Action("Read:bucket-a/prefix-a/*"),
Action("Write:bucket-a/prefix-a/*"),
},
}
iam.identities = []*Identity{scoped}
t.Run("source within scope is allowed", func(t *testing.T) {
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-a/src.bin")
errCode := iam.AuthorizeCopySource(req, scoped, "bucket-a", "prefix-a/src.bin", "")
assert.Equal(t, s3err.ErrNone, errCode)
})
t.Run("source outside scope is denied", func(t *testing.T) {
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-b/src.bin")
errCode := iam.AuthorizeCopySource(req, scoped, "bucket-a", "prefix-b/src.bin", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
})
t.Run("source in different bucket is denied", func(t *testing.T) {
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/other-bucket/src.bin")
errCode := iam.AuthorizeCopySource(req, scoped, "other-bucket", "src.bin", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
})
}
// TestAuthorizeCopySource_IAMIntegrationGetsSourceResource verifies that the
// STS / IAM path is invoked with the SOURCE bucket/object and a GetObject-style
// action — not the destination from the request URL.
func TestAuthorizeCopySource_IAMIntegrationGetsSourceResource(t *testing.T) {
var capturedAction Action
var capturedBucket, capturedObject string
var capturedMethod string
var capturedURLPath string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
// Resolve the action like S3IAMIntegration.AuthorizeAction would,
// so the assertion reflects the s3:* the policy engine sees.
capturedAction = Action(ResolveS3Action(r, string(action), bucket, object))
capturedBucket = bucket
capturedObject = object
if r != nil {
capturedMethod = r.Method
if r.URL != nil {
capturedURLPath = r.URL.Path
}
}
// Deny: simulate a session policy that does not grant Read on src.
return s3err.ErrAccessDenied
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.identities = []*Identity{{Name: "sts"}}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/scopedRole/session",
Account: &AccountAdmin,
Actions: []Action{}, // STS identity
PrincipalArn: "arn:aws:sts::assumed-role/scopedRole/session",
}
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-b/src.bin")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "prefix-b/src.bin", "")
assert.Equal(t, s3err.ErrAccessDenied, errCode)
assert.True(t, mock.authCalled, "IAM integration must be invoked for STS source check")
// Action passed through ResolveS3Action: GET method + ACTION_READ -> s3:GetObject.
assert.Equal(t, "s3:GetObject", string(capturedAction))
assert.Equal(t, "bucket-a", capturedBucket)
assert.Equal(t, "prefix-b/src.bin", capturedObject)
assert.Equal(t, http.MethodGet, capturedMethod, "synthetic source request must be a GET")
assert.Equal(t, "/bucket-a/prefix-b/src.bin", capturedURLPath, "synthetic URL must target the source")
// Original request must not be mutated by the source check.
assert.Equal(t, http.MethodPut, req.Method)
assert.Equal(t, "/bucket-a/prefix-a/dst.bin", req.URL.Path)
}
// TestAuthorizeCopySource_IAMIntegrationAllow verifies the IAM integration can
// grant access; the source check then returns ErrNone.
func TestAuthorizeCopySource_IAMIntegrationAllow(t *testing.T) {
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.identities = []*Identity{{Name: "sts"}}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
req := newCopyRequest(t, "bucket-a", "prefix-a/dst.bin", "/bucket-a/prefix-a/src.bin")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "prefix-a/src.bin", "")
assert.Equal(t, s3err.ErrNone, errCode)
}
// TestAuthorizeCopySource_VersionIdPropagated verifies that when copying a
// specific source version, the IAM check sees a versionId on the synthetic
// request so that s3:VersionId conditions and s3:GetObjectVersion resolution
// work.
func TestAuthorizeCopySource_VersionIdPropagated(t *testing.T) {
var capturedAction Action
var capturedRawQuery string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
capturedAction = Action(ResolveS3Action(r, string(action), bucket, object))
if r != nil && r.URL != nil {
capturedRawQuery = r.URL.RawQuery
}
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
req := newCopyRequest(t, "bucket-a", "dst.bin", "/bucket-a/src.bin?versionId=abc123")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "abc123")
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "s3:GetObjectVersion", string(capturedAction), "versioned source should resolve to GetObjectVersion")
assert.Equal(t, "versionId=abc123", capturedRawQuery)
}
// TestAuthorizeCopySource_PresignedURLSessionTokenPreserved guards against a
// regression where the synthetic source request dropped X-Amz-Security-Token
// from the original query string. Presigned URLs carry the STS token there
// (not in headers), and VerifyActionPermission/authorizeWithIAM use it to
// route the request to IAM authorization.
func TestAuthorizeCopySource_PresignedURLSessionTokenPreserved(t *testing.T) {
var capturedSessionToken string
var capturedRawQuery string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
if r != nil {
capturedSessionToken = r.URL.Query().Get("X-Amz-Security-Token")
capturedRawQuery = r.URL.RawQuery
}
// The IAM mock receives the propagated session token via IAMIdentity too.
if identity != nil && identity.SessionToken != "" {
capturedSessionToken = identity.SessionToken
}
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
// Presigned URL: session token is in the query string, no
// X-Amz-Security-Token header. The destination URL also carries unrelated
// SigV4 query params (X-Amz-Algorithm, etc.) that must not leak into the
// synthetic source action resolution.
req, err := http.NewRequest(http.MethodPut,
"http://s3.local/bucket-a/dst.bin?X-Amz-Algorithm=AWS4-HMAC-SHA256"+
"&X-Amz-Credential=stsKey%2F20260518%2Fus-east-1%2Fs3%2Faws4_request"+
"&X-Amz-Date=20260518T000000Z&X-Amz-Expires=900"+
"&X-Amz-Security-Token=presigned-session-token"+
"&X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef", nil)
require.NoError(t, err)
req.Header.Set("X-Amz-Copy-Source", "/bucket-a/src.bin")
errCode := iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "")
assert.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, "presigned-session-token", capturedSessionToken,
"session token from presigned-URL query must reach the IAM check")
// versionId omitted, only the token should be present in the synthetic query.
assert.Equal(t, "X-Amz-Security-Token=presigned-session-token", capturedRawQuery)
}
// TestAuthorizeCopySource_PreservesCopySourceHeader verifies that the synthetic
// source request still carries the X-Amz-Copy-Source header so policy condition
// keys like s3:x-amz-copy-source remain available.
func TestAuthorizeCopySource_PreservesCopySourceHeader(t *testing.T) {
var capturedCopySource string
mock := &MockIAMIntegration{
authorizeFunc: func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode {
if r != nil {
capturedCopySource = r.Header.Get("X-Amz-Copy-Source")
}
return s3err.ErrNone
},
}
iam := &IdentityAccessManagement{iamIntegration: mock}
iam.isAuthEnabled = true
stsIdentity := &Identity{
Name: "arn:aws:sts::assumed-role/role/session",
Account: &AccountAdmin,
Actions: []Action{},
PrincipalArn: "arn:aws:sts::assumed-role/role/session",
}
req := newCopyRequest(t, "bucket-a", "dst.bin", "/bucket-a/src.bin")
req.Header.Set("X-Amz-Security-Token", "test-session-token")
_ = iam.AuthorizeCopySource(req, stsIdentity, "bucket-a", "src.bin", "")
assert.Equal(t, "/bucket-a/src.bin", capturedCopySource)
}
+71
View File
@@ -2148,6 +2148,77 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide
return s3err.ErrAccessDenied
}
// AuthorizeCopySource verifies the caller is allowed to read the CopyObject /
// UploadPartCopy source. The Auth middleware only checks the destination
// (s3:PutObject) because routing keys on the request URL; without this call,
// an STS session token scoped to a prefix could copy from any other prefix in
// the same bucket.
//
// The source path is checked against both bucket policy and IAM/identity
// permissions, mirroring the normal request-routed flow but with a synthetic
// GetObject request so action resolution and ARN building target the source.
// Returns s3err.ErrNone when allowed or when auth is disabled.
func (iam *IdentityAccessManagement) AuthorizeCopySource(r *http.Request, identity *Identity, srcBucket, srcObject, srcVersionId string) s3err.ErrorCode {
if !iam.isEnabled() {
return s3err.ErrNone
}
if srcBucket == "" {
return s3err.ErrNone
}
if identity == nil {
return s3err.ErrAccessDenied
}
if identity.isAdmin() {
return s3err.ErrNone
}
srcReq := r.Clone(r.Context())
srcURL := &url.URL{
Scheme: r.URL.Scheme,
Host: r.URL.Host,
Path: "/" + srcBucket + "/" + srcObject,
}
// Build the synthetic source query from scratch so leftover params like
// uploadId/partNumber on UploadPartCopy do not steer ResolveS3Action away
// from s3:GetObject. The session token must still flow through for
// presigned URLs that carry STS credentials in the query string.
srcQuery := make(url.Values)
if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" {
srcQuery.Set("X-Amz-Security-Token", token)
}
if srcVersionId != "" {
srcQuery.Set("versionId", srcVersionId)
}
if len(srcQuery) > 0 {
srcURL.RawQuery = srcQuery.Encode()
}
srcReq.URL = srcURL
srcReq.Method = http.MethodGet
srcReq.RequestURI = ""
srcReq.Body = nil
srcReq.GetBody = nil
srcReq.ContentLength = 0
action := s3_constants.ACTION_READ
if iam.policyEngine != nil {
principal := buildPrincipalARN(identity, srcReq)
allowed, evaluated, err := iam.policyEngine.EvaluatePolicy(srcBucket, srcObject, action, principal, srcReq, identity.Claims, nil)
if err != nil {
glog.Errorf("CopyObject source policy evaluation failed for %s/%s: %v - denying", srcBucket, srcObject, err)
return s3err.ErrAccessDenied
}
if evaluated {
if allowed {
return s3err.ErrNone
}
return s3err.ErrAccessDenied
}
}
return iam.VerifyActionPermission(srcReq, identity, Action(action), srcBucket, srcObject)
}
// authorizeWithIAM authorizes requests using the IAM integration policy engine
func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity *Identity, action Action, bucket string, object string) s3err.ErrorCode {
ctx := r.Context()
+30
View File
@@ -88,6 +88,14 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
return
}
// CopyObject requires s3:GetObject on the source in addition to s3:PutObject
// on the destination. The Auth middleware only checked the destination, so
// verify the source explicitly here.
if errCode := s3a.authorizeCopySource(r, srcBucket, srcObject, srcVersionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Get detailed versioning state for source bucket
srcVersioningState, err := s3a.getVersioningState(srcBucket)
if err != nil {
@@ -547,6 +555,21 @@ func (s3a *S3ApiServer) resolveSuspendedCopySourceEntry(bucket, normalizedObject
return s3a.getLatestObjectVersion(bucket, normalizedObject)
}
// authorizeCopySource enforces s3:GetObject on the CopyObject / UploadPartCopy
// source. The route's Auth middleware only verifies destination permissions
// because the request URL points at the destination; the source from the
// X-Amz-Copy-Source header must be authorized separately.
func (s3a *S3ApiServer) authorizeCopySource(r *http.Request, srcBucket, srcObject, srcVersionId string) s3err.ErrorCode {
if s3a.iam == nil || !s3a.iam.isEnabled() {
return s3err.ErrNone
}
var identity *Identity
if id, ok := s3_constants.GetIdentityFromContext(r).(*Identity); ok {
identity = id
}
return s3a.iam.AuthorizeCopySource(r, identity, srcBucket, srcObject, srcVersionId)
}
func (s3a *S3ApiServer) canUseMetadataOnlySelfCopy(entry *filer_pb.Entry, r *http.Request, bucket, object string) bool {
srcPath := fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), s3_constants.NormalizeObjectKey(object))
state := DetectEncryptionStateWithEntry(entry, r, srcPath, srcPath)
@@ -649,6 +672,13 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req
return
}
// UploadPartCopy requires s3:GetObject on the source. The Auth middleware
// only verified s3:PutObject (s3:UploadPart) on the destination.
if errCode := s3a.authorizeCopySource(r, srcBucket, srcObject, srcVersionId); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
partIDString := r.URL.Query().Get("partNumber")
uploadID := r.URL.Query().Get("uploadId")