mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-05-25 11:10:20 +00:00
* 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.
305 lines
12 KiB
Go
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)
|
|
}
|