package s3api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/require"
)
func newMiscTestServer(t *testing.T, bucket string) *S3ApiServer {
t.Helper()
s3a := &S3ApiServer{
iam: &IdentityAccessManagement{isAuthEnabled: true},
bucketConfigCache: NewBucketConfigCache(time.Minute),
}
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket})
return s3a
}
func newBucketRequest(method, bucket, query, body string) *http.Request {
req := httptest.NewRequest(method, "/"+bucket+"?"+query, strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
return req
}
func TestHasExplicitBucketACL(t *testing.T) {
cases := []struct {
name string
headers map[string]string
want bool
}{
{name: "none", headers: nil, want: false},
{name: "private is default", headers: map[string]string{s3_constants.AmzCannedAcl: "private"}, want: false},
{name: "canned public-read", headers: map[string]string{s3_constants.AmzCannedAcl: "public-read"}, want: true},
{name: "canned case-insensitive private", headers: map[string]string{s3_constants.AmzCannedAcl: "PRIVATE"}, want: false},
{name: "grant read", headers: map[string]string{s3_constants.AmzAclRead: `id="x"`}, want: true},
{name: "grant full control", headers: map[string]string{s3_constants.AmzAclFullControl: `id="x"`}, want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := newBucketRequest(http.MethodPut, "b", "", "")
for k, v := range tc.headers {
req.Header.Set(k, v)
}
if got := hasExplicitBucketACL(req); got != tc.want {
t.Fatalf("hasExplicitBucketACL = %v, want %v", got, tc.want)
}
})
}
}
func TestGetBucketPolicyStatusIsPublic(t *testing.T) {
cases := []struct {
name string
raw string
want bool
}{
{
name: "public allow star",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
want: true,
},
{
name: "deny is not public",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
want: false,
},
{
name: "condition makes it non-public",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`,
want: false,
},
{
name: "specific principal is not public",
raw: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"arn:aws:iam::1:user/a","Action":"s3:GetObject","Resource":"arn:aws:s3:::b/*"}]}`,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var doc policy_engine.PolicyDocument
if err := json.Unmarshal([]byte(tc.raw), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := isPolicyPublic(&doc); got != tc.want {
t.Fatalf("isPolicyPublic = %v, want %v", got, tc.want)
}
})
}
}
func TestPutBucketRequestPaymentBucketOwner(t *testing.T) {
s3a := newMiscTestServer(t, "b")
body := `BucketOwner`
req := newBucketRequest(http.MethodPut, "b", "requestPayment=", body)
rec := httptest.NewRecorder()
s3a.PutBucketRequestPaymentHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestPutBucketRequestPaymentRequesterRejected(t *testing.T) {
s3a := newMiscTestServer(t, "b")
body := `Requester`
req := newBucketRequest(http.MethodPut, "b", "requestPayment=", body)
rec := httptest.NewRecorder()
s3a.PutBucketRequestPaymentHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "MalformedXML") {
t.Fatalf("body missing MalformedXML: %s", rec.Body.String())
}
}
func TestPutBucketOwnershipControlsRejectsRuleWithoutObjectOwnership(t *testing.T) {
ownerID := AccountAdmin.Id
s3a := &S3ApiServer{
bucketRegistry: NewBucketRegistry(nil),
}
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{
Name: "b",
Owner: &s3.Owner{
ID: &ownerID,
},
})
body := ``
req := newBucketRequest(http.MethodPut, "b", "ownershipControls=", body)
req.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
rec := httptest.NewRecorder()
s3a.PutBucketOwnershipControls(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "InvalidRequest") {
t.Fatalf("body missing InvalidRequest: %s", rec.Body.String())
}
}
func TestGetBucketOwnershipControlsDefaultsToBucketOwnerEnforced(t *testing.T) {
ownerID := AccountAdmin.Id
s3a := newMiscTestServer(t, "b")
s3a.bucketRegistry = NewBucketRegistry(nil)
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{
Name: "b",
Owner: &s3.Owner{ID: &ownerID},
})
req := newBucketRequest(http.MethodGet, "b", "ownershipControls=", "")
req.Header.Set(s3_constants.AmzAccountId, AccountAdmin.Id)
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), &Identity{
Name: "admin",
Account: &AccountAdmin,
Actions: []Action{s3_constants.ACTION_ADMIN},
}))
rec := httptest.NewRecorder()
s3a.GetBucketOwnershipControls(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), ""+s3_constants.OwnershipBucketOwnerEnforced+"") {
t.Fatalf("body missing default ownership: %s", rec.Body.String())
}
}
func TestGetBucketAccelerateConfiguration(t *testing.T) {
s3a := newMiscTestServer(t, "b")
req := newBucketRequest(http.MethodGet, "b", "accelerate=", "")
rec := httptest.NewRecorder()
s3a.GetBucketAccelerateConfigurationHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body, err := io.ReadAll(rec.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
got := string(body)
if !strings.Contains(got, "Suspended") {
t.Fatalf("missing Suspended status: %s", got)
}
if !strings.Contains(got, `xmlns="http://s3.amazonaws.com/doc/2006-03-01/"`) {
t.Fatalf("missing xmlns: %s", got)
}
}
func TestGetBucketLogging(t *testing.T) {
s3a := newMiscTestServer(t, "b")
req := newBucketRequest(http.MethodGet, "b", "logging=", "")
rec := httptest.NewRecorder()
s3a.GetBucketLoggingHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
got := rec.Body.String()
if !strings.Contains(got, "admin` +
`adminFULL_CONTROL` +
``
req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"?acl", strings.NewReader(body))
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
rr := httptest.NewRecorder()
s3a.iam.Auth(s3a.PutBucketAclHandler, s3_constants.ACTION_WRITE_ACP)(rr, req)
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
}
// An ownerless bucket created before the admin fallback stored an anonymous
// full-control grantee; GetBucketAcl reports it verbatim so a GET-to-PUT
// round trip keeps the stored grant. A client-supplied account header with
// auth off cannot claim ownership either.
func TestGetBucketAclReportsAnonymousGrantVerbatim(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "")
t.Setenv("AWS_SECRET_ACCESS_KEY", "")
const bucket = "acl-legacy-bucket"
filer := &fakeLookupFiler{entry: &filer_pb.Entry{Name: bucket, IsDirectory: true}}
s3a := newHeadBucketTestServer(t, filer)
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
t.Cleanup(s3a.iam.Shutdown)
require.False(t, s3a.iam.isEnabled())
fullControl := s3_constants.PermissionFullControl
grants := []*s3.Grant{{
Grantee: &s3.Grantee{
Type: aws.String("CanonicalUser"),
ID: aws.String(s3_constants.AccountAnonymousId),
},
Permission: &fullControl,
}}
raw, err := json.Marshal(grants)
require.NoError(t, err)
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, ACL: raw})
req := httptest.NewRequest(http.MethodGet, "http://s3/"+bucket+"?acl", http.NoBody)
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
req.Header.Set(s3_constants.AmzAccountId, "spoofed-owner")
rr := httptest.NewRecorder()
s3a.iam.Auth(s3a.GetBucketAclHandler, s3_constants.ACTION_READ_ACP)(rr, req)
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
require.Contains(t, rr.Body.String(), ""+s3_constants.AccountAdminId+"")
require.NotContains(t, rr.Body.String(), "spoofed-owner")
require.Contains(t, rr.Body.String(), s3_constants.AccountAnonymousId)
}
// A bucket with a recorded owner keeps it through an ACL update: the stored
// owner and the full-control grantee stay the same account, whether the
// request carries an account or none.
func TestPutBucketAclKeepsOwner(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "")
t.Setenv("AWS_SECRET_ACCESS_KEY", "")
const bucket = "acl-owned-bucket"
for _, account := range []string{"", "admin"} {
filer := &fakeLookupFiler{entry: &filer_pb.Entry{
Name: bucket,
IsDirectory: true,
Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte("team-a")},
}}
s3a := newHeadBucketTestServer(t, filer)
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"?acl", http.NoBody)
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
req.Header.Set(s3_constants.AmzCannedAcl, "private")
if account != "" {
req.Header.Set(s3_constants.AmzAccountId, account)
}
rr := httptest.NewRecorder()
s3a.iam.Auth(s3a.PutBucketAclHandler, s3_constants.ACTION_WRITE_ACP)(rr, req)
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
s3a.iam.Shutdown()
require.Len(t, filer.txnSeen, 1)
require.NotContains(t, filer.txnSeen[0].Mutations[0].DeleteExtended, s3_constants.ExtAmzOwnerKey)
var grants []*s3.Grant
require.NoError(t, json.Unmarshal(filer.txnSeen[0].Mutations[0].SetExtended[s3_constants.ExtAmzAclKey], &grants))
require.Equal(t, "team-a", aws.StringValue(grants[0].Grantee.ID))
}
}