diff --git a/weed/s3api/auth_copy_source_test.go b/weed/s3api/auth_copy_source_test.go new file mode 100644 index 0000000000..746f16009f --- /dev/null +++ b/weed/s3api/auth_copy_source_test.go @@ -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) +} diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 8716b51a35..cc8e6d1d74 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -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() diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go index 2fd32e3375..383ef21314 100644 --- a/weed/s3api/s3api_object_handlers_copy.go +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -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")