Files
seaweedfs/weed/s3api/auth_copy_source_test.go
Chris Lu c61d227613 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.
2026-05-18 21:35:53 -07:00

305 lines
12 KiB
Go

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)
}