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