mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 23:09:33 +01:00
* s3api: split upload ACL assembly out of preparePutObjectACL Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: store POST Object acl and grant fields on the object The form acl and x-amz-grant-* fields were authorized but never assembled, so the object kept no ACL: acl=private on a public-read bucket stayed anonymously readable and invalid values were accepted. Build them with the same rules as PutObject, which also covers the Block Public Access check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test POST Object stores its upload ACL Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: take the POST Object ACL from the signed form only ACL request headers are not covered by the POST policy, so a holder of a signed form could add one and have it stored with the upload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: reject a POST acl field the policy does not cover The acl form field now sets the stored object ACL, so like the x-amz-* fields it needs a policy condition, as AWS requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test unsigned ACL headers and uncovered acl fields on POST Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: evaluate POST upload policies on the canonical ACL Policy conditions on the grant headers saw the raw form value, so an equivalent spelling of a denied grantee passed. Use the PutObject request shape that the PUT path evaluates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: check bucket policy for POST ACLs with auth disabled PutObject applies the bucket policy to an explicit upload ACL when IAM is off; POST now does the same before storing it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: reject differing acl and x-amz-acl POST fields Both promote to the canned ACL header, so map order picked the stored ACL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test POST grant canonicalization, auth-off ACL policy, acl conflicts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: check the auth-off POST ACL policy after loading the bucket Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: reject an empty acl or x-amz-acl POST field beside a differing one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test the auth-off POST ACL policy on a cold bucket cache and empty acl aliases Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: load the bucket before per-key object authorization Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test the POST ACL policy on a cold bucket cache with auth on Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: stop per-key object authorization when the bucket fails to load Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: test the cold-bucket POST ACL allow path and a failed bucket load Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
760 lines
33 KiB
Go
760 lines
33 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
postAuthzAccessKey = "AKIATESTTESTTEST"
|
|
postAuthzSecretKey = "secret-key-for-tests"
|
|
postAuthzRegion = "us-east-1"
|
|
postAuthzService = "s3"
|
|
postAuthzBucket = "testbucket"
|
|
postAuthzPrincipal = "arn:aws:iam::000000000000:user/tester"
|
|
|
|
postAclAccessKey = "AKIAACLTESTTEST"
|
|
postAclSecretKey = "secret-key-for-acl-tests"
|
|
postAclPrincipal = "arn:aws:iam::000000000000:user/aclwriter"
|
|
|
|
postPolicyAccessKey = "AKIAPOLICYONLY0000"
|
|
postPolicySecretKey = "secret-key-for-policy-only"
|
|
postReadAccessKey = "AKIAPOLICYREAD0000"
|
|
postReadSecretKey = "secret-key-for-policy-read"
|
|
|
|
postAdminAccessKey = "AKIAADMINTEST000"
|
|
postAdminSecretKey = "secret-key-for-admin-tests"
|
|
)
|
|
|
|
// newPostAuthzServer builds an S3ApiServer whose non-admin "tester" identity
|
|
// holds the coarse Write action on the bucket and whose bucket policy denies it
|
|
// s3:PutObject under denied/: a writer confined by a bucket policy to part of a
|
|
// shared bucket.
|
|
func newPostAuthzServer(t *testing.T) *S3ApiServer {
|
|
t.Helper()
|
|
|
|
iam := &IdentityAccessManagement{
|
|
hashes: make(map[string]*sync.Pool),
|
|
hashCounters: make(map[string]*int32),
|
|
}
|
|
err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
|
|
Identities: []*iam_pb.Identity{{
|
|
Name: "tester",
|
|
Account: &iam_pb.Account{Id: "000000000000", DisplayName: "tester"},
|
|
Credentials: []*iam_pb.Credential{{AccessKey: postAuthzAccessKey, SecretKey: postAuthzSecretKey}},
|
|
Actions: []string{"Read:" + postAuthzBucket, "Write:" + postAuthzBucket, "List:" + postAuthzBucket},
|
|
}, {
|
|
Name: "aclwriter",
|
|
Account: &iam_pb.Account{Id: "000000000000", DisplayName: "aclwriter"},
|
|
Credentials: []*iam_pb.Credential{{AccessKey: postAclAccessKey, SecretKey: postAclSecretKey}},
|
|
Actions: []string{"Read:" + postAuthzBucket, "Write:" + postAuthzBucket, "WriteAcp:" + postAuthzBucket},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
policyEngine := NewBucketPolicyEngine()
|
|
err = policyEngine.engine.SetBucketPolicy(postAuthzBucket, fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": "DenyTesterDeniedPrefix",
|
|
"Effect": "Deny",
|
|
"Principal": {"AWS": "%s"},
|
|
"Action": ["s3:PutObject"],
|
|
"Resource": ["arn:aws:s3:::%s/denied/*"]
|
|
}, {
|
|
"Sid": "DenyAclWriterAclPrefix",
|
|
"Effect": "Deny",
|
|
"Principal": {"AWS": "%s"},
|
|
"Action": ["s3:PutObjectAcl"],
|
|
"Resource": ["arn:aws:s3:::%s/acl-denied/*"]
|
|
}]
|
|
}`, postAuthzPrincipal, postAuthzBucket, postAclPrincipal, postAuthzBucket))
|
|
require.NoError(t, err)
|
|
iam.policyEngine = policyEngine
|
|
|
|
s3a := &S3ApiServer{
|
|
option: &S3ApiServerOption{BucketsPath: "/buckets"},
|
|
iam: iam,
|
|
policyEngine: policyEngine,
|
|
}
|
|
// Pre-populate the bucket registry so validateTableBucketObjectPath sees a
|
|
// non-table bucket without needing a live filer connection.
|
|
s3a.bucketRegistry = NewBucketRegistry(s3a)
|
|
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: postAuthzBucket, IsTableBucket: false})
|
|
return s3a
|
|
}
|
|
|
|
// newSignedPostRequest builds a signed multipart POST Object request whose POST
|
|
// policy conditions are satisfied by the form, so the handler passes signature
|
|
// and CheckPostPolicy verification and reaches the write-authorization stage.
|
|
func newSignedPostRequest(t *testing.T, key string) *http.Request {
|
|
return newSignedPostRequestFor(t, key, postAuthzAccessKey, postAuthzSecretKey, nil)
|
|
}
|
|
|
|
// extraFields are added to both the form and the policy conditions so
|
|
// CheckPostPolicy accepts them.
|
|
func newSignedPostRequestFor(t *testing.T, key, accessKey, secretKey string, extraFields map[string]string) *http.Request {
|
|
t.Helper()
|
|
|
|
now := time.Now().UTC()
|
|
amzDate := now.Format(iso8601Format)
|
|
yyyymmddStr := now.Format(yyyymmdd)
|
|
credential := fmt.Sprintf("%s/%s/%s/%s/aws4_request", accessKey, yyyymmddStr, postAuthzRegion, postAuthzService)
|
|
expiration := now.Add(1 * time.Hour).Format("2006-01-02T15:04:05.000Z")
|
|
|
|
extraKeys := make([]string, 0, len(extraFields))
|
|
for k := range extraFields {
|
|
extraKeys = append(extraKeys, k)
|
|
}
|
|
sort.Strings(extraKeys)
|
|
conditions := []any{
|
|
[]any{"eq", "$bucket", postAuthzBucket},
|
|
[]any{"eq", "$key", key},
|
|
[]any{"eq", "$x-amz-credential", credential},
|
|
[]any{"eq", "$x-amz-algorithm", "AWS4-HMAC-SHA256"},
|
|
[]any{"eq", "$x-amz-date", amzDate},
|
|
}
|
|
for _, k := range extraKeys {
|
|
conditions = append(conditions, []any{"eq", "$" + k, extraFields[k]})
|
|
}
|
|
policyJSON, err := json.Marshal(map[string]any{
|
|
"expiration": expiration,
|
|
"conditions": conditions,
|
|
})
|
|
require.NoError(t, err)
|
|
encodedPolicy := base64.StdEncoding.EncodeToString(policyJSON)
|
|
signature := getSignature(getSigningKey(secretKey, yyyymmddStr, postAuthzRegion, postAuthzService), encodedPolicy)
|
|
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
require.NoError(t, writer.WriteField("bucket", postAuthzBucket))
|
|
require.NoError(t, writer.WriteField("key", key))
|
|
require.NoError(t, writer.WriteField("x-amz-credential", credential))
|
|
require.NoError(t, writer.WriteField("x-amz-algorithm", "AWS4-HMAC-SHA256"))
|
|
require.NoError(t, writer.WriteField("x-amz-date", amzDate))
|
|
for _, k := range extraKeys {
|
|
require.NoError(t, writer.WriteField(k, extraFields[k]))
|
|
}
|
|
require.NoError(t, writer.WriteField("policy", encodedPolicy))
|
|
require.NoError(t, writer.WriteField("x-amz-signature", signature))
|
|
filePart, err := writer.CreateFormFile("file", "payload.txt")
|
|
require.NoError(t, err)
|
|
_, err = filePart.Write([]byte("payload"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, writer.Close())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/"+postAuthzBucket, &buf)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
return mux.SetURLVars(req, map[string]string{"bucket": postAuthzBucket})
|
|
}
|
|
|
|
// TestPostPolicyBucketHandlerHonorsBucketPolicyDeny drives a signed POST Object
|
|
// upload whose key a bucket policy denies the caller, and asserts the handler
|
|
// rejects it with 403 -- the same result the equivalent PUT gives, now that POST
|
|
// runs the shared per-key authorization instead of only the coarse Write check.
|
|
func TestPostPolicyBucketHandlerHonorsBucketPolicyDeny(t *testing.T) {
|
|
s3a := newPostAuthzServer(t)
|
|
|
|
// Precondition: the policy really denies this principal the denied/ key, so a
|
|
// non-403 below is the POST path not consulting it, not a mis-configured policy.
|
|
putReq := httptest.NewRequest(http.MethodPut, "/"+postAuthzBucket+"/denied/secret.txt", nil)
|
|
allowed, evaluated, err := s3a.iam.policyEngine.EvaluatePolicy(
|
|
postAuthzBucket, "denied/secret.txt", s3_constants.ACTION_WRITE, postAuthzPrincipal, putReq, nil, nil)
|
|
require.NoError(t, err)
|
|
require.True(t, evaluated, "policy must match the denied key for the tester principal")
|
|
require.False(t, allowed, "policy must deny the tester principal on the denied key")
|
|
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequest(t, "denied/secret.txt"))
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
|
"POST Object into a bucket-policy-denied key must be rejected, body: %s", rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), "AccessDenied",
|
|
"response should identify AccessDenied, body: %s", rec.Body.String())
|
|
}
|
|
|
|
// TestAuthorizeObjectWrite covers the write-authorization decision the POST
|
|
// handler defers to: a bucket-policy Deny blocks the key, a key the policy does
|
|
// not deny clears (the coarse Write action still grants it), and admins are
|
|
// unaffected. Driving the full handler for the allowed case is not hermetic --
|
|
// past authorization it reaches the lifecycle/write path that needs a filer --
|
|
// so the counter-cases are checked at the authorization boundary directly.
|
|
func TestAuthorizeObjectWrite(t *testing.T) {
|
|
s3a := newPostAuthzServer(t)
|
|
tester, _, found := s3a.iam.lookupByAccessKey(postAuthzAccessKey)
|
|
require.True(t, found)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/"+postAuthzBucket, nil)
|
|
|
|
assert.Equal(t, s3err.ErrAccessDenied,
|
|
s3a.iam.AuthorizeObjectWrite(req, tester, postAuthzBucket, "denied/secret.txt"),
|
|
"a bucket-policy-denied key must be denied")
|
|
assert.Equal(t, s3err.ErrNone,
|
|
s3a.iam.AuthorizeObjectWrite(req, tester, postAuthzBucket, "allowed/report.txt"),
|
|
"a key the policy does not deny must clear on the coarse Write grant")
|
|
|
|
admin := &Identity{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}
|
|
assert.Equal(t, s3err.ErrNone,
|
|
s3a.iam.AuthorizeObjectWrite(req, admin, postAuthzBucket, "denied/secret.txt"),
|
|
"an admin is not confined by the bucket policy")
|
|
}
|
|
|
|
// TestPostPolicyBucketHandlerGatesAclFields drives signed POST uploads carrying
|
|
// an acl form field and asserts the handler rejects them the way the PUT path
|
|
// does: a writer without the WriteAcp action is refused, and so is a writer the
|
|
// bucket policy denies s3:PutObjectAcl on the target key.
|
|
func TestPostPolicyBucketHandlerGatesAclFields(t *testing.T) {
|
|
s3a := newPostAuthzServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "allowed/report.txt",
|
|
postAuthzAccessKey, postAuthzSecretKey, map[string]string{"acl": "public-read"}))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
|
"POST with an acl field needs WriteAcp like PUT, body: %s", rec.Body.String())
|
|
|
|
rec = httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "acl-denied/secret.txt",
|
|
postAclAccessKey, postAclSecretKey, map[string]string{"acl": "public-read"}))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
|
"POST with an acl field must honor the bucket-policy PutObjectAcl deny, body: %s", rec.Body.String())
|
|
|
|
for _, field := range []string{"x-amz-grant-read", "x-amz-grant-write", "x-amz-grant-read-acp", "x-amz-grant-write-acp", "x-amz-grant-full-control"} {
|
|
rec = httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "allowed/report.txt",
|
|
postAuthzAccessKey, postAuthzSecretKey,
|
|
map[string]string{field: `id="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"`}))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
|
"POST with an %s field needs WriteAcp like PUT, body: %s", field, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPostRequestsAcl(t *testing.T) {
|
|
for _, header := range []string{
|
|
s3_constants.AmzCannedAcl, s3_constants.AmzAclFullControl, s3_constants.AmzAclRead,
|
|
s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp,
|
|
} {
|
|
req := httptest.NewRequest(http.MethodPost, "/"+postAuthzBucket, nil)
|
|
req.Header.Set(header, "x")
|
|
assert.True(t, postRequestsAcl(req), "%s must trip the ACL gate", header)
|
|
}
|
|
assert.False(t, postRequestsAcl(httptest.NewRequest(http.MethodPost, "/"+postAuthzBucket, nil)))
|
|
}
|
|
|
|
// TestAuthorizeObjectAclWrite covers the acl authorization decision at the
|
|
// boundary, for the same reason as TestAuthorizeObjectWrite.
|
|
func TestAuthorizeObjectAclWrite(t *testing.T) {
|
|
s3a := newPostAuthzServer(t)
|
|
tester, _, found := s3a.iam.lookupByAccessKey(postAuthzAccessKey)
|
|
require.True(t, found)
|
|
aclwriter, _, found := s3a.iam.lookupByAccessKey(postAclAccessKey)
|
|
require.True(t, found)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/"+postAuthzBucket, nil)
|
|
|
|
assert.Equal(t, s3err.ErrAccessDenied,
|
|
s3a.iam.AuthorizeObjectAclWrite(req, tester, postAuthzBucket, "allowed/report.txt"),
|
|
"a writer without the WriteAcp action cannot attach an ACL")
|
|
assert.Equal(t, s3err.ErrNone,
|
|
s3a.iam.AuthorizeObjectAclWrite(req, aclwriter, postAuthzBucket, "allowed/report.txt"),
|
|
"the WriteAcp holder clears keys the policy does not deny")
|
|
assert.Equal(t, s3err.ErrAccessDenied,
|
|
s3a.iam.AuthorizeObjectAclWrite(req, aclwriter, postAuthzBucket, "acl-denied/secret.txt"),
|
|
"a bucket-policy PutObjectAcl deny must block the ACL-bearing upload")
|
|
|
|
admin := &Identity{Name: "admin", Actions: []Action{s3_constants.ACTION_ADMIN}}
|
|
assert.Equal(t, s3err.ErrNone,
|
|
s3a.iam.AuthorizeObjectAclWrite(req, admin, postAuthzBucket, "acl-denied/secret.txt"),
|
|
"an admin is not confined by the bucket policy")
|
|
}
|
|
|
|
// newPostPolicyOnlyServer builds an S3ApiServer with identities that hold no
|
|
// legacy Write action: "poster" carries an attached IAM policy allowing
|
|
// s3:PutObject on the test bucket, and "reader" one allowing only s3:GetObject.
|
|
func newPostPolicyOnlyServer(t *testing.T) *S3ApiServer {
|
|
t.Helper()
|
|
|
|
iam := &IdentityAccessManagement{
|
|
hashes: make(map[string]*sync.Pool),
|
|
hashCounters: make(map[string]*int32),
|
|
}
|
|
err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
|
|
Identities: []*iam_pb.Identity{{
|
|
Name: "poster",
|
|
Account: &iam_pb.Account{Id: "000000000000", DisplayName: "poster"},
|
|
Credentials: []*iam_pb.Credential{{AccessKey: postPolicyAccessKey, SecretKey: postPolicySecretKey}},
|
|
Actions: []string{"List:" + postAuthzBucket},
|
|
PolicyNames: []string{"post-write"},
|
|
}, {
|
|
Name: "reader",
|
|
Account: &iam_pb.Account{Id: "000000000000", DisplayName: "reader"},
|
|
Credentials: []*iam_pb.Credential{{AccessKey: postReadAccessKey, SecretKey: postReadSecretKey}},
|
|
Actions: []string{"Read:" + postAuthzBucket, "List:" + postAuthzBucket},
|
|
PolicyNames: []string{"post-read"},
|
|
}},
|
|
Policies: []*iam_pb.Policy{{
|
|
Name: "post-write",
|
|
Content: fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Allow",
|
|
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
|
|
"Resource": ["arn:aws:s3:::%s/*"]
|
|
}, {
|
|
"Effect": "Allow",
|
|
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
|
|
"Resource": ["arn:aws:s3:::%s"]
|
|
}]
|
|
}`, postAuthzBucket, postAuthzBucket),
|
|
}, {
|
|
Name: "post-read",
|
|
Content: fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Allow",
|
|
"Action": ["s3:GetObject"],
|
|
"Resource": ["arn:aws:s3:::%s/*"]
|
|
}]
|
|
}`, postAuthzBucket),
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
s3a := &S3ApiServer{
|
|
option: &S3ApiServerOption{BucketsPath: "/buckets"},
|
|
iam: iam,
|
|
}
|
|
s3a.bucketRegistry = NewBucketRegistry(s3a)
|
|
s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: postAuthzBucket, IsTableBucket: false})
|
|
return s3a
|
|
}
|
|
|
|
// postFormValues canonicalizes the signed request's multipart fields the way
|
|
// extractPostPolicyFormValues does, for driving doesPolicySignatureMatch.
|
|
func postFormValues(t *testing.T, r *http.Request) http.Header {
|
|
t.Helper()
|
|
require.NoError(t, r.ParseMultipartForm(1<<20))
|
|
formValues := make(http.Header)
|
|
for k, v := range r.MultipartForm.Value {
|
|
formValues[http.CanonicalHeaderKey(k)] = v
|
|
}
|
|
return formValues
|
|
}
|
|
|
|
// TestPostPolicySignatureAllowsAttachedPolicyIdentity signs a POST Object form
|
|
// with a key whose holder has no legacy Write action - only an attached IAM
|
|
// policy granting s3:PutObject - and asserts the signature check accepts it
|
|
// and the shared object-write authorization allows the key. Gating the
|
|
// signature check on the Actions projection used to fail this with
|
|
// AccessDenied before authorization ever ran.
|
|
func TestPostPolicySignatureAllowsAttachedPolicyIdentity(t *testing.T) {
|
|
s3a := newPostPolicyOnlyServer(t)
|
|
|
|
req := newSignedPostRequestFor(t, "uploads/pic.png", postPolicyAccessKey, postPolicySecretKey, nil)
|
|
identity, errCode := s3a.iam.doesPolicySignatureMatch(req.Context(), postFormValues(t, req))
|
|
require.Equal(t, s3err.ErrNone, errCode,
|
|
"a verified POST signature must not require the legacy Write action")
|
|
require.NotNil(t, identity)
|
|
|
|
assert.Equal(t, s3err.ErrNone,
|
|
s3a.iam.AuthorizeObjectWrite(req, identity, postAuthzBucket, "uploads/pic.png"),
|
|
"an attached IAM policy granting s3:PutObject must authorize the key like the PUT path")
|
|
}
|
|
|
|
// TestPostPolicyAttachedPolicyDeny covers the deny leg: a verified identity
|
|
// whose attached policy does not grant s3:PutObject is still refused by
|
|
// AuthorizeObjectWrite and by the full handler.
|
|
func TestPostPolicyAttachedPolicyDeny(t *testing.T) {
|
|
s3a := newPostPolicyOnlyServer(t)
|
|
|
|
req := newSignedPostRequestFor(t, "uploads/pic.png", postReadAccessKey, postReadSecretKey, nil)
|
|
identity, errCode := s3a.iam.doesPolicySignatureMatch(req.Context(), postFormValues(t, req))
|
|
require.Equal(t, s3err.ErrNone, errCode)
|
|
require.NotNil(t, identity)
|
|
|
|
assert.Equal(t, s3err.ErrAccessDenied,
|
|
s3a.iam.AuthorizeObjectWrite(req, identity, postAuthzBucket, "uploads/pic.png"),
|
|
"an attached policy without s3:PutObject must still deny the upload")
|
|
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "uploads/pic.png", postReadAccessKey, postReadSecretKey, nil))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code,
|
|
"POST from a read-only identity must stay AccessDenied, body: %s", rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), "AccessDenied")
|
|
}
|
|
|
|
// newPostAutoCreateServer builds an S3ApiServer on the fake filer with
|
|
// auto-create enabled, an "admin" identity allowed to create buckets, and the
|
|
// non-admin "tester" writer.
|
|
func newPostAutoCreateServer(t *testing.T, filer *ambiguousPutFiler) *S3ApiServer {
|
|
t.Helper()
|
|
|
|
iam := &IdentityAccessManagement{
|
|
hashes: make(map[string]*sync.Pool),
|
|
hashCounters: make(map[string]*int32),
|
|
}
|
|
err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
|
|
Identities: []*iam_pb.Identity{{
|
|
Name: "admin",
|
|
Account: &iam_pb.Account{Id: "000000000000", DisplayName: "admin"},
|
|
Credentials: []*iam_pb.Credential{{AccessKey: postAdminAccessKey, SecretKey: postAdminSecretKey}},
|
|
Actions: []string{"Admin"},
|
|
}, {
|
|
Name: "tester",
|
|
Account: &iam_pb.Account{Id: "000000000000", DisplayName: "tester"},
|
|
Credentials: []*iam_pb.Credential{{AccessKey: postAuthzAccessKey, SecretKey: postAuthzSecretKey}},
|
|
Actions: []string{"Read:" + postAuthzBucket, "Write:" + postAuthzBucket, "List:" + postAuthzBucket},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
s3a := newFailoverTestServer(t, startFakeFiler(t, filer))
|
|
s3a.iam = iam
|
|
s3a.option.BucketsPath = "/buckets"
|
|
s3a.option.AutoCreateBucket = true
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
return s3a
|
|
}
|
|
|
|
// A signed POST Object upload to a bucket that does not exist must lazy-create
|
|
// it the way PutObject does: an admin signer is allowed to create the bucket
|
|
// and the upload proceeds against it, landing the object entry and its chunk
|
|
// instead of answering NoSuchBucket.
|
|
func TestPostPolicyBucketHandlerAutoCreatesMissingBucket(t *testing.T) {
|
|
volume := startFakeVolumeServer(t)
|
|
filer := &ambiguousPutFiler{
|
|
volume: volume,
|
|
entries: map[string]*filer_pb.Entry{},
|
|
apply: true,
|
|
}
|
|
s3a := newPostAutoCreateServer(t, filer)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "uploads/pic.png",
|
|
postAdminAccessKey, postAdminSecretKey, map[string]string{"success_action_status": "201"}))
|
|
|
|
assert.Equal(t, http.StatusCreated, rec.Code,
|
|
"POST to a missing bucket must complete the upload after creating it, body: %s", rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), "<Key>uploads/pic.png</Key>")
|
|
|
|
filer.mu.Lock()
|
|
defer filer.mu.Unlock()
|
|
bucketEntry := filer.entries["/buckets/"+postAuthzBucket]
|
|
require.NotNil(t, bucketEntry, "POST to a missing bucket must create it like PUT")
|
|
assert.True(t, bucketEntry.IsDirectory)
|
|
assert.Equal(t, []byte("admin"), bucketEntry.Extended[s3_constants.AmzIdentityId])
|
|
assert.Equal(t, []byte("000000000000"), bucketEntry.Extended[s3_constants.ExtAmzOwnerKey])
|
|
objectEntry := filer.entries["/buckets/"+postAuthzBucket+"/uploads/pic.png"]
|
|
require.NotNil(t, objectEntry, "the upload must land its object entry on the new bucket")
|
|
assert.NotEmpty(t, objectEntry.Chunks)
|
|
|
|
volume.mu.Lock()
|
|
defer volume.mu.Unlock()
|
|
var stored []byte
|
|
for _, data := range volume.stored {
|
|
stored = append(stored, data...)
|
|
}
|
|
assert.Contains(t, string(stored), "payload", "the file body must reach the assigned volume")
|
|
}
|
|
|
|
// A non-admin signer keeps the PUT semantics: the upload itself is authorized
|
|
// but the lazy create is refused, surfacing AccessDenied rather than creating
|
|
// the bucket or answering NoSuchBucket.
|
|
func TestPostPolicyBucketHandlerAutoCreateDeniedForNonAdmin(t *testing.T) {
|
|
volume := startFakeVolumeServer(t)
|
|
filer := &ambiguousPutFiler{
|
|
volume: volume,
|
|
entries: map[string]*filer_pb.Entry{},
|
|
apply: true,
|
|
}
|
|
s3a := newPostAutoCreateServer(t, filer)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "uploads/pic.png", postAuthzAccessKey, postAuthzSecretKey, nil))
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), "AccessDenied")
|
|
|
|
filer.mu.Lock()
|
|
defer filer.mu.Unlock()
|
|
assert.Empty(t, filer.entries, "a non-admin POST must not create the bucket")
|
|
}
|
|
|
|
// A bucket that blocks public ACLs refuses a POST upload whose acl or grant
|
|
// fields would make the object public, and still accepts a private one.
|
|
func TestPostPolicyBucketHandlerBlocksPublicAcls(t *testing.T) {
|
|
newServer := func(t *testing.T) (*S3ApiServer, *ambiguousPutFiler) {
|
|
filer := &ambiguousPutFiler{
|
|
volume: startFakeVolumeServer(t),
|
|
entries: map[string]*filer_pb.Entry{"/buckets/" + postAuthzBucket: {
|
|
Name: postAuthzBucket,
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{},
|
|
Extended: map[string][]byte{
|
|
s3_constants.ExtAmzOwnerKey: []byte("000000000000"),
|
|
s3_constants.ExtPublicAccessBlockKey: []byte(`<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls></PublicAccessBlockConfiguration>`),
|
|
},
|
|
}},
|
|
apply: true,
|
|
}
|
|
return newPostAutoCreateServer(t, filer), filer
|
|
}
|
|
const key = "uploads/pic.png"
|
|
for _, fields := range []map[string]string{
|
|
{"acl": "public-read"},
|
|
{"acl": "authenticated-read"},
|
|
{"x-amz-grant-read": `uri="` + s3_constants.GranteeGroupAllUsers + `"`},
|
|
} {
|
|
s3a, filer := newServer(t)
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, fields))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code, "%v must be refused, body: %s", fields, rec.Body.String())
|
|
assert.Contains(t, rec.Body.String(), "AccessDenied")
|
|
filer.mu.Lock()
|
|
assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key], "%v must not write the object", fields)
|
|
filer.mu.Unlock()
|
|
}
|
|
|
|
s3a, filer := newServer(t)
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey,
|
|
map[string]string{"acl": "private", "success_action_status": "201"}))
|
|
assert.Equal(t, http.StatusCreated, rec.Code, "a private POST must succeed, body: %s", rec.Body.String())
|
|
filer.mu.Lock()
|
|
defer filer.mu.Unlock()
|
|
assert.NotNil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key])
|
|
}
|
|
|
|
// A POST Object acl field is stored on the object like PUT's x-amz-acl, so a
|
|
// private upload into a public-read bucket is not anonymously readable.
|
|
func TestPostPolicyBucketHandlerStoresObjectAcl(t *testing.T) {
|
|
const key = "uploads/pic.png"
|
|
upload := func(t *testing.T, publicBucket bool, fields, headers map[string]string) (*httptest.ResponseRecorder, *S3ApiServer, *filer_pb.Entry) {
|
|
bucketEntry := &filer_pb.Entry{
|
|
Name: postAuthzBucket,
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{},
|
|
Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte("000000000000")},
|
|
}
|
|
if publicBucket {
|
|
bucketEntry.Extended[s3_constants.ExtAmzAclKey] = anonymousReadGrant(s3_constants.PermissionRead)
|
|
}
|
|
filer := &ambiguousPutFiler{
|
|
volume: startFakeVolumeServer(t),
|
|
entries: map[string]*filer_pb.Entry{"/buckets/" + postAuthzBucket: bucketEntry},
|
|
apply: true,
|
|
}
|
|
s3a := newPostAutoCreateServer(t, filer)
|
|
req := newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, fields)
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, req)
|
|
filer.mu.Lock()
|
|
defer filer.mu.Unlock()
|
|
return rec, s3a, filer.entries["/buckets/"+postAuthzBucket+"/"+key]
|
|
}
|
|
anonymousRead := func(s3a *S3ApiServer, entry *filer_pb.Entry) s3err.ErrorCode {
|
|
req := httptest.NewRequest(http.MethodGet, "/"+postAuthzBucket+"/"+key, nil)
|
|
return s3a.authorizeAnonymousObjectRead(req, postAuthzBucket, key, entry.Extended)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
publicBucket bool
|
|
fields map[string]string
|
|
headers map[string]string
|
|
want s3err.ErrorCode
|
|
}{
|
|
{"private on public-read bucket", true, map[string]string{"acl": "private"}, nil, s3err.ErrAccessDenied},
|
|
{"no acl on public-read bucket", true, nil, nil, s3err.ErrAccessDenied},
|
|
{"public-read on private bucket", false, map[string]string{"acl": "public-read"}, nil, s3err.ErrNone},
|
|
{"grant-read AllUsers on private bucket", false, map[string]string{"x-amz-grant-read": `uri="` + s3_constants.GranteeGroupAllUsers + `"`}, nil, s3err.ErrNone},
|
|
{"unsigned acl header", false, nil, map[string]string{s3_constants.AmzCannedAcl: "public-read"}, s3err.ErrAccessDenied},
|
|
{"unsigned grant header", false, nil, map[string]string{s3_constants.AmzAclRead: `uri="` + s3_constants.GranteeGroupAllUsers + `"`}, s3err.ErrAccessDenied},
|
|
{"unsigned header beside signed acl", false, map[string]string{"acl": "private"}, map[string]string{s3_constants.AmzAclRead: `uri="` + s3_constants.GranteeGroupAllUsers + `"`}, s3err.ErrAccessDenied},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec, s3a, entry := upload(t, tc.publicBucket, tc.fields, tc.headers)
|
|
require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
|
|
require.NotNil(t, entry)
|
|
assert.Equal(t, []byte("000000000000"), entry.Extended[s3_constants.ExtAmzOwnerKey])
|
|
assert.NotEmpty(t, entry.Extended[s3_constants.ExtAmzAclKey], "the upload ACL must be stored")
|
|
assert.Equal(t, tc.want, anonymousRead(s3a, entry))
|
|
})
|
|
}
|
|
|
|
t.Run("invalid canned acl", func(t *testing.T) {
|
|
rec, _, entry := upload(t, false, map[string]string{"acl": "bogus-acl"}, nil)
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
|
assert.Nil(t, entry, "a rejected ACL must not write the object")
|
|
})
|
|
t.Run("canned and grant together", func(t *testing.T) {
|
|
rec, _, entry := upload(t, false, map[string]string{"acl": "private", "x-amz-grant-read": `uri="` + s3_constants.GranteeGroupAllUsers + `"`}, nil)
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
|
assert.Nil(t, entry)
|
|
})
|
|
}
|
|
|
|
// newPostAclWriterServer serves the policy-confined identities of
|
|
// newPostAuthzServer against a fake filer holding the test bucket.
|
|
func newPostAclWriterServer(t *testing.T) (*S3ApiServer, *ambiguousPutFiler) {
|
|
filer := &ambiguousPutFiler{
|
|
volume: startFakeVolumeServer(t),
|
|
entries: map[string]*filer_pb.Entry{"/buckets/" + postAuthzBucket: {
|
|
Name: postAuthzBucket,
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{},
|
|
Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte("000000000000")},
|
|
}},
|
|
apply: true,
|
|
}
|
|
s3a := newPostAutoCreateServer(t, filer)
|
|
authz := newPostAuthzServer(t)
|
|
s3a.iam, s3a.policyEngine = authz.iam, authz.policyEngine
|
|
return s3a, filer
|
|
}
|
|
|
|
// Policy conditions on a POST grant see the canonical grant list, as on PUT,
|
|
// so a respelled grantee does not slip past a deny.
|
|
func TestPostPolicyBucketHandlerCanonicalGrantCondition(t *testing.T) {
|
|
for _, grant := range []string{`id="000000000000"`, `id = "000000000000"`, `id="00000000000\u0030"`} {
|
|
s3a, filer := newPostAclWriterServer(t)
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy(postAuthzBucket, fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Effect": "Deny",
|
|
"Principal": {"AWS": "%s"},
|
|
"Action": ["s3:PutObjectAcl"],
|
|
"Resource": ["arn:aws:s3:::%s/*"],
|
|
"Condition": {"StringEquals": {"s3:x-amz-grant-read": "id=\"000000000000\""}}
|
|
}]
|
|
}`, postAclPrincipal, postAuthzBucket)))
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "allowed/report.txt",
|
|
postAclAccessKey, postAclSecretKey, map[string]string{"x-amz-grant-read": grant}))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code, "grant %s, body: %s", grant, rec.Body.String())
|
|
filer.mu.Lock()
|
|
assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/allowed/report.txt"], "grant %s", grant)
|
|
filer.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// With authentication off, a bucket policy still governs a POST upload ACL,
|
|
// as it does a PutObject ACL, including on the first request after start.
|
|
func TestPostPolicyBucketHandlerAclPolicyWithAuthDisabled(t *testing.T) {
|
|
const key = "uploads/pic.png"
|
|
s3a, filer := newPostAclWriterServer(t)
|
|
s3a.iam = &IdentityAccessManagement{}
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
filer.mu.Lock()
|
|
filer.entries["/buckets/"+postAuthzBucket].Extended[BUCKET_POLICY_METADATA_KEY] = []byte(fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{"Effect": "Deny", "Principal": "*", "Action": ["s3:PutObjectAcl"], "Resource": ["arn:aws:s3:::%s/*"]}]
|
|
}`, postAuthzBucket))
|
|
filer.mu.Unlock()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, map[string]string{"acl": "public-read"}))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
|
filer.mu.Lock()
|
|
assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key])
|
|
filer.mu.Unlock()
|
|
|
|
rec = httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, nil))
|
|
assert.Equal(t, http.StatusNoContent, rec.Code, "an upload without an ACL is not a PutObjectAcl, body: %s", rec.Body.String())
|
|
}
|
|
|
|
// newColdPostAclWriterServer is newPostAclWriterServer with an empty policy
|
|
// engine that only learns the bucket policy when the bucket loads.
|
|
func newColdPostAclWriterServer(t *testing.T, policy string) (*S3ApiServer, *ambiguousPutFiler) {
|
|
s3a, filer := newPostAclWriterServer(t)
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
s3a.iam.policyEngine = s3a.policyEngine
|
|
s3a.iam.primeBucketForIAM = func(bucket string) s3err.ErrorCode {
|
|
_, code := s3a.getBucketConfig(bucket)
|
|
return code
|
|
}
|
|
if policy != "" {
|
|
filer.mu.Lock()
|
|
filer.entries["/buckets/"+postAuthzBucket].Extended[BUCKET_POLICY_METADATA_KEY] = []byte(policy)
|
|
filer.mu.Unlock()
|
|
}
|
|
return s3a, filer
|
|
}
|
|
|
|
// With authentication on, a persisted bucket policy also governs a POST
|
|
// upload ACL on the first request after start.
|
|
func TestPostPolicyBucketHandlerAclPolicyOnColdBucket(t *testing.T) {
|
|
const key = "allowed/report.txt"
|
|
aclField := map[string]string{"acl": "public-read"}
|
|
|
|
s3a, _ := newColdPostAclWriterServer(t, "")
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAclAccessKey, postAclSecretKey, aclField))
|
|
assert.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String())
|
|
|
|
s3a, filer := newColdPostAclWriterServer(t, fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{"Effect": "Deny", "Principal": {"AWS": "%s"}, "Action": ["s3:PutObjectAcl"], "Resource": ["arn:aws:s3:::%s/*"]}]
|
|
}`, postAclPrincipal, postAuthzBucket))
|
|
rec = httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAclAccessKey, postAclSecretKey, aclField))
|
|
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
|
|
filer.mu.Lock()
|
|
assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key])
|
|
filer.mu.Unlock()
|
|
|
|
rec = httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAclAccessKey, postAclSecretKey, nil))
|
|
assert.Equal(t, http.StatusNoContent, rec.Code, "an upload without an ACL is not a PutObjectAcl, body: %s", rec.Body.String())
|
|
}
|
|
|
|
// A bucket that fails to load cannot have its policy evaluated, so the
|
|
// upload stops instead of falling back to the identity's grants.
|
|
func TestPostPolicyBucketHandlerStopsWhenBucketLoadFails(t *testing.T) {
|
|
const key = "allowed/report.txt"
|
|
s3a, filer := newColdPostAclWriterServer(t, "")
|
|
s3a.iam.primeBucketForIAM = func(string) s3err.ErrorCode { return s3err.ErrInternalError }
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAclAccessKey, postAclSecretKey, nil))
|
|
assert.Equal(t, http.StatusInternalServerError, rec.Code, "body: %s", rec.Body.String())
|
|
filer.mu.Lock()
|
|
assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key])
|
|
filer.mu.Unlock()
|
|
}
|
|
|
|
func TestPostPolicyBucketHandlerRejectsConflictingAclFields(t *testing.T) {
|
|
for _, fields := range []map[string]string{
|
|
{"acl": "private", "x-amz-acl": "public-read"},
|
|
{"acl": "public-read", "x-amz-acl": ""},
|
|
{"acl": "", "x-amz-acl": "public-read"},
|
|
} {
|
|
s3a, filer := newPostAclWriterServer(t)
|
|
rec := httptest.NewRecorder()
|
|
s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, "allowed/report.txt", postAclAccessKey, postAclSecretKey, fields))
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code, "fields %v, body: %s", fields, rec.Body.String())
|
|
filer.mu.Lock()
|
|
assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/allowed/report.txt"], "fields %v", fields)
|
|
filer.mu.Unlock()
|
|
}
|
|
}
|