mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-05 22:12:04 +02:00
* s3api: persist ACLs on PutObject uploads Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3api: fix PutObject ACL edge cases found in review - Only enforce BucketOwnerEnforced when explicitly configured; buckets without a stored ownership control keep accepting upload ACLs - Ignore ACL query parameters on SigV2 requests, which do not sign them - Mirror signed-query ACL values into headers after authentication so grant parsing and resolveFileMode agree on presigned uploads - Validate only caller-supplied grantees against the account registry; default grants now work for accounts outside the local registry - Reject unknown grantee keys and accept comma-separated grantee lists without spaces in ParseCustomAclHeader - Guard against identities without an account * s3api: harden upload ACL parsing and authorization Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3api: evaluate upload ACL grantees individually in policies A comma-joined grant header or a signed query parameter reached policy conditions as one value, so a deny on a later grantee did not fire. Split grant headers into per-grantee values for policy evaluation and share the grantee pair parser with ParseCustomAclHeader. * s3api: keep raw grant header values visible to policy conditions Exact-match conditions written against the signed header value stopped matching once grantees were split for evaluation. Preserve the original wire values alongside the per-grantee values so deny policies fire on either granularity. * s3api: evaluate upload ACL grants as one canonical list in policies Conditions on s3:x-amz-grant-* now see a single comma-separated canonical grant list identical for a single line, repeated header lines, or a signed query parameter. This keeps StringEquals allows and exact-list or allowlist (StringNotEquals) denies accurate regardless of wire encoding. * s3api: preserve upload ACL denies and align policy checks Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3api: retain upload owner grants and literal policy values Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> --------- Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
707 lines
43 KiB
Go
707 lines
43 KiB
Go
package s3api
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"hash/crc32"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// TestPutObjectUploadACL exercises signed uploads and inspects the actual filer
|
|
// entry, including rejection before any volume allocation or object replacement.
|
|
func TestPutObjectUploadACL(t *testing.T) {
|
|
const bucket, object, writer, bucketOwner = "acl-bucket", "allowed/image.png", "upload-writer", "bucket-owner"
|
|
type uploadACLTest struct {
|
|
name, acl, grantHeader, grant, ownership, policy, versioning, errorCode string
|
|
writeOnly, wrongScope, marker, presigned, overwrite, unsigned, streaming bool
|
|
status int
|
|
signature string
|
|
query, afterSigning url.Values
|
|
grantees []string
|
|
repeatedGrant string
|
|
conditionValue string
|
|
conditionOperator string
|
|
defaultMode uint32
|
|
unregisteredAccounts bool
|
|
policyOnly bool
|
|
route bool
|
|
copySource string
|
|
grantAccount, grantEmail string
|
|
unregisteredWriter bool
|
|
}
|
|
tests := []uploadACLTest{
|
|
{name: "default private", status: 200},
|
|
{name: "explicit private", acl: "private", status: 200},
|
|
{name: "public read", acl: "public-read", status: 200},
|
|
{name: "public read write", acl: "public-read-write", status: 200},
|
|
{name: "authenticated read", acl: "authenticated-read", status: 200},
|
|
{name: "custom read", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, status: 200},
|
|
{name: "custom write", grantHeader: s3_constants.AmzAclWrite, grant: `id="bucket-owner"`, status: 200},
|
|
{name: "custom read acp", grantHeader: s3_constants.AmzAclReadAcp, grant: `id="bucket-owner"`, status: 200},
|
|
{name: "custom write acp", grantHeader: s3_constants.AmzAclWriteAcp, grant: `id="bucket-owner"`, status: 200},
|
|
{name: "custom full control", grantHeader: s3_constants.AmzAclFullControl, grant: `id="bucket-owner"`, status: 200},
|
|
{name: "custom owner full control is not duplicated", grantHeader: s3_constants.AmzAclFullControl, grant: `id="upload-writer"`, grantees: []string{writer}, status: 200},
|
|
{name: "custom owner email full control is not duplicated", grantHeader: s3_constants.AmzAclFullControl, grant: `emailAddress="writer@example.com"`, grantEmail: "writer@example.com", grantAccount: writer, grantees: []string{writer}, status: 200},
|
|
{name: "custom owner read retains full control", grantHeader: s3_constants.AmzAclRead, grant: `id="upload-writer"`, grantees: []string{writer}, status: 200},
|
|
{name: "custom dynamic writer keeps full control", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, unregisteredWriter: true, status: 200},
|
|
{name: "unknown grantee", grantHeader: s3_constants.AmzAclRead, grant: `id="unknown"`, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "unknown canned acl", acl: "invalid", status: 400, errorCode: "InvalidRequest"},
|
|
{name: "conflicting acl headers", acl: "public-read", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "bucket owner read", acl: "bucket-owner-read", status: 200},
|
|
{name: "bucket owner full control", acl: "bucket-owner-full-control", status: 200},
|
|
{name: "preferred ownership", acl: "bucket-owner-full-control", ownership: s3_constants.OwnershipBucketOwnerPreferred, status: 200},
|
|
{name: "preferred default private", ownership: s3_constants.OwnershipBucketOwnerPreferred, status: 200},
|
|
{name: "enforced default", ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 200},
|
|
{name: "enforced full control", acl: "bucket-owner-full-control", ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 200},
|
|
{name: "enforced rejects private", acl: "private", ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 400, errorCode: "AccessControlListNotSupported"},
|
|
{name: "enforced rejects public", acl: "public-read", ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 400, errorCode: "AccessControlListNotSupported"},
|
|
{name: "enforced rejects grants", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 400, errorCode: "AccessControlListNotSupported"},
|
|
{name: "write only default", writeOnly: true, status: 200},
|
|
{name: "write only rejects explicit private", acl: "private", writeOnly: true, status: 403, errorCode: "AccessDenied"},
|
|
{name: "write only rejects public", acl: "public-read", writeOnly: true, status: 403, errorCode: "AccessDenied"},
|
|
{name: "write only rejects grants", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, writeOnly: true, status: 403, errorCode: "AccessDenied"},
|
|
{name: "acl permission outside prefix", acl: "public-read", wrongScope: true, status: 403, errorCode: "AccessDenied"},
|
|
{name: "iam allows acl", acl: "public-read", writeOnly: true, policy: "iam-allow", status: 200},
|
|
{name: "iam denies acl", acl: "public-read", policy: "iam-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "bucket allows acl", acl: "public-read", writeOnly: true, policy: "bucket-allow", status: 200},
|
|
{name: "bucket denies acl", acl: "public-read", policy: "bucket-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "presigned public read", acl: "public-read", presigned: true, status: 200},
|
|
{name: "presigned requires acl permission", acl: "public-read", presigned: true, writeOnly: true, status: 403, errorCode: "AccessDenied"},
|
|
{name: "presigned custom read", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, presigned: true, status: 200},
|
|
{name: "unsigned with authentication disabled", acl: "public-read", unsigned: true, status: 200},
|
|
{name: "streaming unsigned payload", acl: "public-read", streaming: true, status: 200},
|
|
{name: "directory marker", acl: "public-read", marker: true, status: 200},
|
|
{name: "directory marker rejects acl", acl: "public-read", marker: true, writeOnly: true, status: 403, errorCode: "AccessDenied"},
|
|
{name: "suspended version", acl: "public-read", versioning: s3_constants.VersioningSuspended, status: 200},
|
|
{name: "enabled version", acl: "public-read", versioning: s3_constants.VersioningEnabled, status: 200},
|
|
{name: "overwrite resets private", overwrite: true, status: 200},
|
|
{name: "rejected overwrite preserves acl", overwrite: true, acl: "invalid", status: 400, errorCode: "InvalidRequest"},
|
|
// Preserve the upstream review fixes and legacy ownership behavior.
|
|
{name: "multi grantee without space", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, grantees: []string{bucketOwner, writer}, status: 200},
|
|
{name: "unknown grantee key", grantHeader: s3_constants.AmzAclRead, grant: `account="bucket-owner"`, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "absent ownership default", ownership: "absent", status: 200},
|
|
{name: "absent ownership public read", acl: "public-read", ownership: "absent", status: 200},
|
|
{name: "absent ownership grants", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, ownership: "absent", status: 200},
|
|
{name: "sigv2 ignores unsigned query acl", signature: "v2", afterSigning: url.Values{"X-Amz-Acl": {"public-read"}}, status: 200},
|
|
{name: "external account uploader", acl: "public-read", unregisteredAccounts: true, status: 200},
|
|
{name: "unknown grant type", grantHeader: s3_constants.AmzAclRead, grant: `account="bucket-owner"`, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "mixed unknown grant type", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",principal="upload-writer"`, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "multiple grants without spaces", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, grantees: []string{bucketOwner, writer}, status: 200},
|
|
{name: "repeated grant headers", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`, grantees: []string{bucketOwner, writer}, status: 200},
|
|
{name: "presigned multiple grants", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, grantees: []string{bucketOwner, writer}, presigned: true, status: 200},
|
|
{name: "presigned public read write", acl: "public-read-write", presigned: true, status: 200},
|
|
{name: "presigned public version", acl: "public-read", presigned: true, versioning: s3_constants.VersioningEnabled, status: 200},
|
|
{name: "default server mode", defaultMode: 0600, status: 200},
|
|
{name: "enforced default server mode", ownership: s3_constants.OwnershipBucketOwnerEnforced, defaultMode: 0600, status: 200},
|
|
{name: "v2 signed header", signature: "v2", acl: "public-read", status: 200},
|
|
{name: "v2 presigned default", signature: "v2", presigned: true, status: 200},
|
|
{name: "v2 presigned signed header", signature: "v2-header", presigned: true, acl: "public-read", status: 200},
|
|
{name: "v2 unsigned canned query", signature: "v2", presigned: true, acl: "public-read", status: 200},
|
|
{name: "v2 unsigned grant query", signature: "v2", presigned: true, grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, status: 200},
|
|
{name: "v2 tampered mixed case query", signature: "v2", afterSigning: url.Values{"x-AMZ-aCl": {"public-read"}}, status: 200},
|
|
{name: "v2 tampered presigned query", signature: "v2", presigned: true, afterSigning: url.Values{"x-amz-acl": {"public-read"}}, status: 200},
|
|
{name: "v2 empty acl query", signature: "v2", afterSigning: url.Values{"x-amz-acl": {""}}, status: 200},
|
|
{name: "v2 header cannot hide unsigned query", signature: "v2", acl: "private", afterSigning: url.Values{"x-amz-acl": {"public-read"}}, status: 200},
|
|
{name: "v2 fake v4 marker cannot bypass", signature: "v2", afterSigning: url.Values{"x-amz-acl": {"public-read"}, "X-Amz-Credential": {"fake"}}, status: 200},
|
|
{name: "v4 duplicate canned query", presigned: true, query: url.Values{"X-Amz-Acl": {"private", "public-read"}}, afterSigning: url.Values{"X-Amz-Acl": {"public-read", "private"}}, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "v4 case alias query", presigned: true, query: url.Values{"X-Amz-Acl": {"private"}, "x-amz-acl": {"public-read"}}, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "v4 conflicting header query", acl: "private", query: url.Values{"X-Amz-Acl": {"public-read"}}, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "v4 tampered signed query", presigned: true, acl: "private", afterSigning: url.Values{"X-Amz-Acl": {"public-read"}}, status: 403, errorCode: "SignatureDoesNotMatch"},
|
|
{name: "dynamic default writer", unregisteredAccounts: true, status: 200},
|
|
{name: "dynamic public writer", unregisteredAccounts: true, acl: "public-read", status: 200},
|
|
{name: "dynamic bucket owner", unregisteredAccounts: true, acl: "bucket-owner-full-control", status: 200},
|
|
{name: "dynamic preferred owner", unregisteredAccounts: true, acl: "bucket-owner-full-control", ownership: s3_constants.OwnershipBucketOwnerPreferred, status: 200},
|
|
{name: "dynamic enforced default", unregisteredAccounts: true, ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 200},
|
|
{name: "dynamic enforced full control", unregisteredAccounts: true, acl: "bucket-owner-full-control", ownership: s3_constants.OwnershipBucketOwnerEnforced, status: 200},
|
|
{name: "dynamic does not bypass custom validation", unregisteredAccounts: true, grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, status: 400, errorCode: "InvalidRequest"},
|
|
{name: "disabled authentication bucket denies acl", unsigned: true, acl: "public-read", policy: "bucket-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "header bucket condition denies acl", acl: "public-read", policy: "bucket-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "query bucket condition denies acl", acl: "public-read", presigned: true, policy: "bucket-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "query bucket condition denies upload", acl: "public-read", presigned: true, policy: "bucket-put-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "query iam condition denies acl", acl: "public-read", presigned: true, policy: "iam-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "query iam condition denies upload", acl: "public-read", presigned: true, policy: "iam-put-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "query bucket condition allows acl", acl: "public-read", presigned: true, writeOnly: true, policy: "bucket-condition-allow", status: 200},
|
|
{name: "query bucket condition supplies all permissions", acl: "public-read", presigned: true, policyOnly: true, policy: "bucket-all-condition-allow", status: 200},
|
|
{name: "query iam condition supplies all permissions", acl: "public-read", presigned: true, policyOnly: true, policy: "iam-all-condition-allow", status: 200},
|
|
{name: "query grant bucket condition denies", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, presigned: true, policy: "bucket-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "query grant iam condition denies", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, presigned: true, policy: "iam-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "disabled authentication query condition denies acl", unsigned: true, presigned: true, acl: "public-read", policy: "bucket-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
{name: "disabled authentication query condition denies upload", unsigned: true, presigned: true, acl: "public-read", policy: "bucket-put-condition-deny", status: 403, errorCode: "AccessDenied"},
|
|
// Policy conditions compare the canonical grant list as a whole, so a
|
|
// deny on the exact list fires identically for repeated header lines, a
|
|
// single comma-joined line, or a signed query parameter.
|
|
{name: "repeated grants preserve condition deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`, policy: "bucket-condition-deny", conditionValue: `id="bucket-owner",id="upload-writer"`, status: 403, errorCode: "AccessDenied"},
|
|
{name: "single line grants preserve condition deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, policy: "bucket-condition-deny", conditionValue: `id="bucket-owner",id="upload-writer"`, status: 403, errorCode: "AccessDenied"},
|
|
{name: "presigned grants preserve condition deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, presigned: true, policy: "bucket-condition-deny", conditionValue: `id="bucket-owner",id="upload-writer"`, status: 403, errorCode: "AccessDenied"},
|
|
{name: "extra grantee defeats allow condition", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`, writeOnly: true, policy: "bucket-condition-allow", conditionValue: `id="bucket-owner"`, status: 403, errorCode: "AccessDenied"},
|
|
}
|
|
// Invalid multipart parameters or copy headers must not bypass policy normalization on the actual regular-upload route.
|
|
for _, policy := range []string{"bucket", "iam"} {
|
|
for _, shape := range []string{"upload id only", "invalid part number", "invalid copy source"} {
|
|
query, copySource := url.Values{}, ""
|
|
switch shape {
|
|
case "upload id only":
|
|
query.Set("uploadId", "opaque")
|
|
case "invalid part number":
|
|
query.Set("uploadId", "opaque")
|
|
query.Set("partNumber", "abc")
|
|
case "invalid copy source":
|
|
copySource = "bogus"
|
|
}
|
|
tests = append(tests, uploadACLTest{
|
|
name: "routed extra grant denied " + policy + " " + shape, route: true, query: query, copySource: copySource,
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`,
|
|
conditionValue: `id="bucket-owner"`, policy: policy + "-all-condition-allow", policyOnly: true,
|
|
status: 403, errorCode: "AccessDenied",
|
|
})
|
|
}
|
|
for _, presigned := range []bool{false, true} {
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("put action approved negative deny %s presigned %t", policy, presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id = "bucket-owner" , id = "upload-writer"`,
|
|
conditionValue: `id="bucket-owner",id="upload-writer"`, conditionOperator: "StringNotEquals",
|
|
policy: policy + "-put-condition-deny", presigned: presigned, grantees: []string{bucketOwner, writer}, status: 200,
|
|
})
|
|
}
|
|
}
|
|
// Every grant header must pass the same policy boundary, not just read grants.
|
|
for _, header := range []string{s3_constants.AmzAclRead, s3_constants.AmzAclWrite, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWriteAcp, s3_constants.AmzAclFullControl} {
|
|
for _, policy := range []string{"bucket", "iam"} {
|
|
for _, presigned := range []bool{false, true} {
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("raw grant header deny %s %s presigned %t", header, policy, presigned),
|
|
grantHeader: header, grant: `id = "bucket-owner"`, policy: policy + "-condition-deny", presigned: presigned,
|
|
status: 403, errorCode: "AccessDenied",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
for _, presigned := range []bool{false, true} {
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("disabled authentication raw deny presigned %t", presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id = "bucket-owner"`, unsigned: true, presigned: presigned,
|
|
policy: "bucket-condition-deny", status: 403, errorCode: "AccessDenied",
|
|
})
|
|
for _, policy := range []string{"bucket", "iam"} {
|
|
for _, repeated := range []bool{false, true} {
|
|
grant, extra := `id="bucket-owner",id="upload-writer"`, ""
|
|
if repeated {
|
|
grant, extra = `id="bucket-owner"`, `id="upload-writer"`
|
|
}
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("whole list deny preserves extra grantee %s presigned %t repeated %t", policy, presigned, repeated),
|
|
grantHeader: s3_constants.AmzAclRead, grant: grant, repeatedGrant: extra,
|
|
conditionValue: `id="bucket-owner"`, policy: policy + "-condition-deny", presigned: presigned,
|
|
grantees: []string{bucketOwner, writer}, status: 200,
|
|
}, uploadACLTest{
|
|
name: fmt.Sprintf("whole list allow rejects extra grantee %s presigned %t repeated %t", policy, presigned, repeated),
|
|
grantHeader: s3_constants.AmzAclRead, grant: grant, repeatedGrant: extra,
|
|
conditionValue: `id="bucket-owner"`, policy: policy + "-all-condition-allow", presigned: presigned,
|
|
policyOnly: true, status: 403, errorCode: "AccessDenied",
|
|
})
|
|
}
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("canonical approved allow with whitespace %s presigned %t", policy, presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id = "bucket-owner" , id = "upload-writer"`,
|
|
conditionValue: `id="bucket-owner",id="upload-writer"`, policy: policy + "-all-condition-allow", presigned: presigned,
|
|
policyOnly: true, grantees: []string{bucketOwner, writer}, status: 200,
|
|
})
|
|
}
|
|
}
|
|
// Explicit denies on original complete grant values must survive whitespace and escape normalization.
|
|
for _, policy := range []string{"bucket", "iam"} {
|
|
for _, presigned := range []bool{false, true} {
|
|
for _, grant := range []string{
|
|
`id="bucket-owner",id="upload-writer"`,
|
|
`id = "bucket-owner" , id = "upload-writer"`,
|
|
`id="bucket-\u006fwner",id="upload-writer"`,
|
|
} {
|
|
for _, operator := range []string{"StringEquals", "StringEqualsIgnoreCase", "StringLike"} {
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("raw grant deny %s %s presigned %t %s", policy, operator, presigned, grant),
|
|
grantHeader: s3_constants.AmzAclRead, grant: grant, presigned: presigned,
|
|
conditionOperator: operator, policy: policy + "-condition-deny", status: 403, errorCode: "AccessDenied",
|
|
})
|
|
}
|
|
}
|
|
// Repeated headers and presigned queries must check all grants; an approved value cannot hide an added grantee.
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("joined repeated raw deny %s presigned %t", policy, presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`,
|
|
conditionValue: `id="bucket-owner",id="upload-writer"`, policy: policy + "-condition-deny", presigned: presigned,
|
|
status: 403, errorCode: "AccessDenied",
|
|
}, uploadACLTest{
|
|
name: fmt.Sprintf("original repeated line deny %s presigned %t", policy, presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id = "bucket-owner"`, repeatedGrant: `id="upload-writer"`,
|
|
conditionValue: `*id = "bucket-owner"*`, conditionOperator: "StringLike", policy: policy + "-condition-deny", presigned: presigned,
|
|
status: 403, errorCode: "AccessDenied",
|
|
})
|
|
// Negative conditions still compare the canonical complete list, preserving equivalent encodings of approved lists.
|
|
for _, operator := range []string{"StringNotEquals", "StringNotLike", "StringNotEqualsIgnoreCase"} {
|
|
for _, grant := range []string{`id="bucket-owner",id="upload-writer"`, `id = "bucket-owner" , id = "upload-writer"`} {
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("approved negative deny %s %s presigned %t %s", policy, operator, presigned, grant),
|
|
grantHeader: s3_constants.AmzAclRead, grant: grant, conditionOperator: operator,
|
|
conditionValue: `id="bucket-owner",id="upload-writer"`, policy: policy + "-condition-deny", presigned: presigned,
|
|
grantees: []string{bucketOwner, writer}, status: 200,
|
|
}, uploadACLTest{
|
|
name: fmt.Sprintf("negative allow unchanged %s %s presigned %t %s", policy, operator, presigned, grant),
|
|
grantHeader: s3_constants.AmzAclRead, grant: grant, conditionOperator: operator,
|
|
conditionValue: `id="bucket-owner",id="upload-writer"`, policy: policy + "-all-condition-allow", presigned: presigned,
|
|
policyOnly: true, status: 403, errorCode: "AccessDenied",
|
|
})
|
|
}
|
|
tests = append(tests, uploadACLTest{
|
|
name: fmt.Sprintf("extra repeated grantee denied %s %s presigned %t", policy, operator, presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`,
|
|
conditionOperator: operator, conditionValue: `id="bucket-owner"`, policy: policy + "-condition-deny", presigned: presigned,
|
|
status: 403, errorCode: "AccessDenied",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
// Special characters must remain literal in canonical policy values, while
|
|
// authorization still checks both upload permissions and complete grant lists.
|
|
for _, grantee := range []struct{ key, value, account string }{
|
|
{"emailAddress", "a&b@example.com", "email-reader"},
|
|
{"id", "reader<account", "reader<account"},
|
|
{"id", "reader>account", "reader>account"},
|
|
} {
|
|
grant := fmt.Sprintf("%s=%q", grantee.key, grantee.value)
|
|
for _, policy := range []string{"bucket", "iam"} {
|
|
for _, presigned := range []bool{false, true} {
|
|
for _, rule := range []struct {
|
|
name, operator, policy string
|
|
status int
|
|
}{
|
|
{"allow", "StringEquals", "-all-condition-allow", 200},
|
|
{"approved negative deny", "StringNotEquals", "-condition-deny", 200},
|
|
{"positive deny", "StringEquals", "-condition-deny", 403},
|
|
} {
|
|
tt := uploadACLTest{
|
|
name: fmt.Sprintf("html grant %s %s %s presigned %t", grantee.value, policy, rule.name, presigned),
|
|
grantHeader: s3_constants.AmzAclRead, grant: grant, grantAccount: grantee.account,
|
|
conditionValue: grant, conditionOperator: rule.operator, policy: policy + rule.policy,
|
|
presigned: presigned, policyOnly: rule.name == "allow", grantees: []string{grantee.account}, status: rule.status,
|
|
}
|
|
if grantee.key == "emailAddress" {
|
|
tt.grantEmail = grantee.value
|
|
}
|
|
if rule.status == 403 {
|
|
tt.errorCode = "AccessDenied"
|
|
}
|
|
tests = append(tests, tt)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
key := object
|
|
if tt.marker {
|
|
key = "allowed/folder/"
|
|
}
|
|
volume := startFakeVolumeServer(t)
|
|
filer := &ambiguousPutFiler{volume: volume, apply: true, entries: map[string]*filer_pb.Entry{}}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, filer))
|
|
s3a.option.DefaultFileMode = tt.defaultMode
|
|
s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory")
|
|
t.Cleanup(s3a.iam.Shutdown)
|
|
s3a.iam.isAuthEnabled = !tt.unsigned
|
|
account := &Account{Id: writer, DisplayName: writer}
|
|
identity := &Identity{Name: "upload-acl-test", Account: account, IsStatic: true,
|
|
Actions: []Action{"Write:acl-bucket/allowed/*"},
|
|
Credentials: []*Credential{{AccessKey: routingTestAccessKey, SecretKey: routingTestSecretKey}}}
|
|
if !tt.writeOnly {
|
|
scope := "WriteAcp:acl-bucket/allowed/*"
|
|
if tt.wrongScope {
|
|
scope = "WriteAcp:acl-bucket/other/*"
|
|
}
|
|
identity.Actions = append(identity.Actions, Action(scope))
|
|
}
|
|
if tt.policyOnly {
|
|
identity.Actions = nil
|
|
}
|
|
s3a.iam.accessKeyIdent[routingTestAccessKey] = identity
|
|
s3a.iam.nameToIdentity[identity.Name] = identity
|
|
s3a.iam.accounts[writer] = account
|
|
s3a.iam.accounts[bucketOwner] = &Account{Id: bucketOwner, DisplayName: bucketOwner}
|
|
if tt.grantAccount != "" {
|
|
grantee := &Account{Id: tt.grantAccount, DisplayName: tt.grantAccount, EmailAddress: tt.grantEmail}
|
|
s3a.iam.accounts[tt.grantAccount] = grantee
|
|
if tt.grantEmail != "" {
|
|
s3a.iam.emailAccount[tt.grantEmail] = grantee
|
|
}
|
|
}
|
|
if tt.unregisteredWriter {
|
|
delete(s3a.iam.accounts, writer)
|
|
}
|
|
if tt.unregisteredAccounts {
|
|
// JWT/STS authentication supplies trusted accounts dynamically;
|
|
// their IDs are not registered in the static grantee directory.
|
|
delete(s3a.iam.accounts, writer)
|
|
delete(s3a.iam.accounts, bucketOwner)
|
|
}
|
|
ownership := tt.ownership
|
|
if ownership == "" {
|
|
ownership = s3_constants.OwnershipObjectWriter
|
|
}
|
|
bucketEntry := &filer_pb.Entry{Name: bucket, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{},
|
|
Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte(bucketOwner)}}
|
|
storedOwnership := ownership
|
|
if ownership == "absent" {
|
|
storedOwnership = ""
|
|
} else {
|
|
bucketEntry.Extended[s3_constants.ExtOwnershipKey] = []byte(ownership)
|
|
}
|
|
filer.entries["/buckets/"+bucket] = bucketEntry
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: storedOwnership, Versioning: tt.versioning})
|
|
s3a.bucketRegistry = NewBucketRegistry(s3a)
|
|
s3a.bucketRegistry.LoadBucketMetadata(bucketEntry)
|
|
if tt.versioning == s3_constants.VersioningEnabled {
|
|
filer.entries["/buckets/"+bucket+"/"+key+s3_constants.VersionsFolder] = &filer_pb.Entry{Name: "image.png.versions", IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
|
|
}
|
|
var original *filer_pb.Entry
|
|
if tt.overwrite {
|
|
original = &filer_pb.Entry{Name: "image.png", Attributes: &filer_pb.FuseAttributes{}, Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte(bucketOwner), s3_constants.ExtAmzAclKey: []byte("old-acl")}}
|
|
filer.entries["/buckets/"+bucket+"/"+key] = proto.Clone(original).(*filer_pb.Entry)
|
|
}
|
|
if tt.policy != "" {
|
|
effect := "Allow"
|
|
if strings.HasSuffix(tt.policy, "deny") {
|
|
effect = "Deny"
|
|
}
|
|
statement := fmt.Sprintf(`{"Effect":%q,"Action":"s3:PutObjectAcl","Resource":"arn:aws:s3:::acl-bucket/allowed/*"}`, effect)
|
|
if strings.Contains(tt.policy, "put-") {
|
|
statement = strings.Replace(statement, `"s3:PutObjectAcl"`, `"s3:PutObject"`, 1)
|
|
} else if strings.Contains(tt.policy, "all-") {
|
|
statement = strings.Replace(statement, `"s3:PutObjectAcl"`, `["s3:PutObject","s3:PutObjectAcl"]`, 1)
|
|
}
|
|
if strings.Contains(tt.policy, "condition") {
|
|
header, value := s3_constants.AmzCannedAcl, tt.acl
|
|
if tt.grantHeader != "" {
|
|
header, value = tt.grantHeader, tt.grant
|
|
}
|
|
if tt.conditionValue != "" {
|
|
value = tt.conditionValue
|
|
}
|
|
operator := tt.conditionOperator
|
|
if operator == "" {
|
|
operator = "StringEquals"
|
|
}
|
|
condition := fmt.Sprintf(`,"Condition":{%q:{%q:%q}}}`, operator, "s3:"+strings.ToLower(header), value)
|
|
statement = strings.TrimSuffix(statement, "}") + condition
|
|
}
|
|
if strings.HasPrefix(tt.policy, "iam") {
|
|
statements := statement
|
|
if !tt.policyOnly {
|
|
allowActions := `"s3:PutObject"`
|
|
if strings.Contains(tt.policy, "condition") && effect == "Deny" {
|
|
allowActions = `["s3:PutObject","s3:PutObjectAcl"]`
|
|
}
|
|
statements = `{"Effect":"Allow","Action":` + allowActions + `,"Resource":"arn:aws:s3:::acl-bucket/allowed/*"},` + statement
|
|
}
|
|
require.NoError(t, s3a.iam.PutPolicy("upload-acl-policy", `{"Version":"2012-10-17","Statement":[`+statements+`]}`))
|
|
identity.PolicyNames = []string{"upload-acl-policy"}
|
|
} else {
|
|
s3a.policyEngine = NewBucketPolicyEngine()
|
|
s3a.iam.policyEngine = s3a.policyEngine
|
|
statement = strings.Replace(statement, `{"Effect":`, `{"Principal":"*","Effect":`, 1)
|
|
require.NoError(t, s3a.policyEngine.engine.SetBucketPolicy(bucket, `{"Version":"2012-10-17","Statement":[`+statement+`]}`))
|
|
}
|
|
}
|
|
body := "uploaded content"
|
|
wireBody := body
|
|
if tt.streaming {
|
|
checksum := crc32.NewIEEE()
|
|
_, err := checksum.Write([]byte(body))
|
|
require.NoError(t, err)
|
|
wireBody = fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\nx-amz-checksum-crc32:%s\r\n\r\n", len(body), body, base64.StdEncoding.EncodeToString(checksum.Sum(nil)))
|
|
}
|
|
req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"/"+key, strings.NewReader(wireBody))
|
|
req = mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": key})
|
|
req.Header.Set("Content-Type", "text/plain")
|
|
if tt.copySource != "" {
|
|
req.Header.Set("X-Amz-Copy-Source", tt.copySource)
|
|
}
|
|
if tt.acl != "" {
|
|
req.Header.Set(s3_constants.AmzCannedAcl, tt.acl)
|
|
}
|
|
if tt.grantHeader != "" {
|
|
req.Header.Set(tt.grantHeader, tt.grant)
|
|
if tt.repeatedGrant != "" {
|
|
req.Header.Add(tt.grantHeader, tt.repeatedGrant)
|
|
}
|
|
}
|
|
req.URL.RawQuery = tt.query.Encode()
|
|
if tt.streaming {
|
|
req.Header.Set("X-Amz-Content-Sha256", streamingUnsignedPayload)
|
|
req.Header.Set("X-Amz-Trailer", "x-amz-checksum-crc32")
|
|
req.Header.Set("X-Amz-Decoded-Content-Length", fmt.Sprint(len(body)))
|
|
req.Header.Set("Content-Encoding", "aws-chunked")
|
|
}
|
|
if tt.presigned && tt.signature != "v2-header" {
|
|
// Exercise ACLs in the signed query rather than relying on a
|
|
// particular SDK version's automatic header-hoisting behavior.
|
|
query := req.URL.Query()
|
|
if tt.acl != "" {
|
|
query.Set(s3_constants.AmzCannedAcl, tt.acl)
|
|
req.Header.Del(s3_constants.AmzCannedAcl)
|
|
}
|
|
if tt.grantHeader != "" {
|
|
query.Set(tt.grantHeader, strings.Join(req.Header.Values(tt.grantHeader), ","))
|
|
req.Header.Del(tt.grantHeader)
|
|
}
|
|
req.URL.RawQuery = query.Encode()
|
|
}
|
|
if tt.unsigned {
|
|
// Disabled authentication uses the admin account, not a caller's
|
|
// forged internal account header.
|
|
req.Header.Set(s3_constants.AmzAccountId, "forged-account")
|
|
}
|
|
if tt.signature != "" {
|
|
cred := &Credential{AccessKey: routingTestAccessKey, SecretKey: routingTestSecretKey}
|
|
if tt.presigned {
|
|
query := req.URL.Query()
|
|
expires := fmt.Sprint(time.Now().Add(time.Minute).Unix())
|
|
query.Set("AWSAccessKeyId", routingTestAccessKey)
|
|
query.Set("Expires", expires)
|
|
query.Set("Signature", preSignatureV2(cred, req.Method, req.URL.EscapedPath(), query.Encode(), req.Header, expires))
|
|
req.URL.RawQuery = query.Encode()
|
|
} else {
|
|
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
|
|
req.Header.Set("Authorization", signatureV2(cred, req.Method, req.URL.EscapedPath(), req.URL.RawQuery, req.Header))
|
|
}
|
|
} else if tt.presigned && !tt.unsigned {
|
|
signer := v4.NewSigner(credentials.NewStaticCredentials(routingTestAccessKey, routingTestSecretKey, ""))
|
|
_, err := signer.Presign(req, strings.NewReader(wireBody), "s3", "us-east-1", time.Minute, time.Now())
|
|
require.NoError(t, err)
|
|
} else if !tt.unsigned {
|
|
signRoutingTestRequest(t, req, wireBody, "s3")
|
|
}
|
|
if tt.afterSigning != nil {
|
|
query := req.URL.Query()
|
|
for key, values := range tt.afterSigning {
|
|
query[key] = values
|
|
}
|
|
req.URL.RawQuery = query.Encode()
|
|
if tt.errorCode != "SignatureDoesNotMatch" {
|
|
// These attacks preserve a valid signature. Unsigned V2 ACLs
|
|
// must be ignored; ambiguous signed V4 ACLs must be rejected.
|
|
_, code := s3a.iam.AuthenticateRequest(req.Clone(req.Context()))
|
|
require.Equal(t, s3err.ErrNone, code)
|
|
}
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
if tt.route {
|
|
s3a.cb = &CircuitBreaker{s3a: s3a}
|
|
router := mux.NewRouter()
|
|
s3a.registerRouter(router)
|
|
router.ServeHTTP(rr, req)
|
|
} else {
|
|
s3a.iam.Auth(s3a.PutObjectHandler, s3_constants.ACTION_WRITE)(rr, req)
|
|
}
|
|
require.Equal(t, tt.status, rr.Code, rr.Body.String())
|
|
// Snapshot the committed entry under the fixture lock, then release it
|
|
// before GetObjectAcl makes another RPC to the fake filer.
|
|
var allocatedChunks uint64
|
|
stored := func() *filer_pb.Entry {
|
|
filer.mu.Lock()
|
|
defer filer.mu.Unlock()
|
|
allocatedChunks = filer.nextKey
|
|
entry := filer.entries["/buckets/"+bucket+"/"+strings.TrimSuffix(key, "/")]
|
|
if tt.status == http.StatusOK && tt.versioning == s3_constants.VersioningEnabled {
|
|
versionID := rr.Header().Get("x-amz-version-id")
|
|
require.NotEmpty(t, versionID)
|
|
entry = nil
|
|
for _, candidate := range filer.entries {
|
|
if string(candidate.Extended[s3_constants.ExtVersionIdKey]) == versionID {
|
|
entry = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
return proto.Clone(entry).(*filer_pb.Entry)
|
|
}()
|
|
if tt.status != http.StatusOK {
|
|
require.Contains(t, rr.Body.String(), "<Code>"+tt.errorCode+"</Code>")
|
|
require.Zero(t, allocatedChunks, "rejected ACLs must not allocate chunks")
|
|
require.True(t, proto.Equal(original, stored), "rejected uploads must not replace the object")
|
|
return
|
|
}
|
|
wantACL, wantGrantHeader := tt.acl, tt.grantHeader
|
|
if strings.HasPrefix(tt.signature, "v2") && tt.presigned && tt.signature != "v2-header" {
|
|
wantACL, wantGrantHeader = "", ""
|
|
}
|
|
require.NotNil(t, stored)
|
|
if !tt.marker {
|
|
mode := defaultFileMode
|
|
if tt.defaultMode != 0 && wantACL == "" {
|
|
mode = tt.defaultMode
|
|
}
|
|
switch wantACL {
|
|
case "public-read", "authenticated-read", "bucket-owner-read":
|
|
mode = 0644
|
|
case "public-read-write":
|
|
mode = 0666
|
|
}
|
|
require.Equal(t, mode, stored.Attributes.FileMode, "header and signed query ACLs must use the same file mode")
|
|
}
|
|
bodyMD5 := md5.Sum([]byte(body))
|
|
require.Equal(t, bodyMD5[:], stored.Attributes.Md5, "ACL parsing must not consume or alter the upload body")
|
|
wantOwner := writer
|
|
if tt.unsigned {
|
|
wantOwner = AccountAdmin.Id
|
|
}
|
|
if s3_constants.EffectiveOwnership(storedOwnership) == s3_constants.OwnershipBucketOwnerEnforced || (ownership == s3_constants.OwnershipBucketOwnerPreferred && wantACL == "bucket-owner-full-control") {
|
|
wantOwner = bucketOwner
|
|
}
|
|
require.Equal(t, wantOwner, string(stored.Extended[s3_constants.ExtAmzOwnerKey]))
|
|
grants := GetAcpGrants(stored.Extended)
|
|
require.NotEmpty(t, grants, "ACL must be persisted in the object create")
|
|
if wantGrantHeader != "" {
|
|
wantGrantees := tt.grantees
|
|
if wantGrantees == nil {
|
|
wantGrantees = []string{bucketOwner}
|
|
}
|
|
wantPermission := map[string]string{
|
|
s3_constants.AmzAclRead: s3_constants.PermissionRead,
|
|
s3_constants.AmzAclWrite: s3_constants.PermissionWrite,
|
|
s3_constants.AmzAclReadAcp: s3_constants.PermissionReadAcp,
|
|
s3_constants.AmzAclWriteAcp: s3_constants.PermissionWriteAcp,
|
|
s3_constants.AmzAclFullControl: s3_constants.PermissionFullControl,
|
|
}[wantGrantHeader]
|
|
ownerFullControl := false
|
|
for _, grantee := range wantGrantees {
|
|
ownerFullControl = ownerFullControl || (grantee == wantOwner && wantPermission == s3_constants.PermissionFullControl)
|
|
}
|
|
wantCount := len(wantGrantees)
|
|
if !ownerFullControl {
|
|
wantCount++
|
|
}
|
|
require.Len(t, grants, wantCount, "custom uploads must retain the owner's full control")
|
|
for i, grantee := range wantGrantees {
|
|
require.Equal(t, grantee, aws.StringValue(grants[i].Grantee.ID))
|
|
require.Equal(t, wantPermission, aws.StringValue(grants[i].Permission))
|
|
}
|
|
if !ownerFullControl {
|
|
ownerGrant := grants[len(grants)-1]
|
|
require.Equal(t, wantOwner, aws.StringValue(ownerGrant.Grantee.ID))
|
|
require.Equal(t, s3_constants.GrantTypeCanonicalUser, aws.StringValue(ownerGrant.Grantee.Type))
|
|
require.Equal(t, s3_constants.PermissionFullControl, aws.StringValue(ownerGrant.Permission))
|
|
}
|
|
} else {
|
|
require.Equal(t, wantOwner, aws.StringValue(grants[0].Grantee.ID))
|
|
require.Equal(t, s3_constants.PermissionFullControl, aws.StringValue(grants[0].Permission))
|
|
if wantACL == "public-read" || wantACL == "public-read-write" || wantACL == "authenticated-read" {
|
|
wantGrants := 2
|
|
if wantACL == "public-read-write" {
|
|
wantGrants = 3
|
|
}
|
|
require.Len(t, grants, wantGrants)
|
|
wantGroup := s3_constants.GranteeGroupAllUsers
|
|
if wantACL == "authenticated-read" {
|
|
wantGroup = s3_constants.GranteeGroupAuthenticatedUsers
|
|
}
|
|
require.Equal(t, wantGroup, aws.StringValue(grants[1].Grantee.URI))
|
|
require.Equal(t, s3_constants.PermissionRead, aws.StringValue(grants[1].Permission))
|
|
if wantACL == "public-read-write" {
|
|
require.Equal(t, s3_constants.GranteeGroupAllUsers, aws.StringValue(grants[2].Grantee.URI))
|
|
require.Equal(t, s3_constants.PermissionWrite, aws.StringValue(grants[2].Permission))
|
|
}
|
|
} else if ownership == s3_constants.OwnershipObjectWriter && strings.HasPrefix(wantACL, "bucket-owner-") {
|
|
require.Len(t, grants, 2)
|
|
require.Equal(t, bucketOwner, aws.StringValue(grants[1].Grantee.ID))
|
|
wantPermission := s3_constants.PermissionRead
|
|
if wantACL == "bucket-owner-full-control" {
|
|
wantPermission = s3_constants.PermissionFullControl
|
|
}
|
|
require.Equal(t, wantPermission, aws.StringValue(grants[1].Permission))
|
|
} else {
|
|
require.Len(t, grants, 1)
|
|
}
|
|
}
|
|
if wantGrantHeader != "" {
|
|
aclRequest := httptest.NewRequest(http.MethodGet, "http://s3/"+bucket+"/"+key+"?acl", nil)
|
|
aclRequest = mux.SetURLVars(aclRequest, map[string]string{"bucket": bucket, "object": key})
|
|
aclRequest.Header.Set(s3_constants.AmzAccountId, wantOwner)
|
|
aclResponse := httptest.NewRecorder()
|
|
s3a.GetObjectAclHandler(aclResponse, aclRequest)
|
|
require.Equal(t, http.StatusOK, aclResponse.Code, aclResponse.Body.String())
|
|
var acl AccessControlPolicy
|
|
require.NoError(t, xml.Unmarshal(aclResponse.Body.Bytes(), &acl))
|
|
require.Equal(t, wantOwner, acl.Owner.ID)
|
|
require.Len(t, acl.AccessControlList.Grant, len(grants))
|
|
for i, grant := range acl.AccessControlList.Grant {
|
|
require.Equal(t, aws.StringValue(grants[i].Grantee.ID), grant.Grantee.ID)
|
|
require.Equal(t, Permission(aws.StringValue(grants[i].Permission)), grant.Permission)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPutObjectACLPolicyScope ensures query normalization cannot alter other
|
|
// operations or the original signed request passed to the upload handler.
|
|
func TestPutObjectACLPolicyScope(t *testing.T) {
|
|
tests := []struct {
|
|
name, method, object, subresource string
|
|
action Action
|
|
copy bool
|
|
repeatedCopy bool
|
|
wantACL string
|
|
}{
|
|
{name: "upload", method: http.MethodPut, object: "key", action: s3_constants.ACTION_WRITE, wantACL: "public-read"},
|
|
{name: "upload acl authorization", method: http.MethodPut, object: "key", action: s3_constants.ACTION_WRITE_ACP, wantACL: "public-read"},
|
|
{name: "bucket", method: http.MethodPut, action: s3_constants.ACTION_WRITE},
|
|
{name: "post form", method: http.MethodPost, object: "key", action: s3_constants.ACTION_WRITE},
|
|
{name: "copy", method: http.MethodPut, object: "key", action: s3_constants.ACTION_WRITE, copy: true},
|
|
{name: "repeated copy source", method: http.MethodPut, object: "key", action: s3_constants.ACTION_WRITE, repeatedCopy: true},
|
|
{name: "multipart part", method: http.MethodPut, object: "key", subresource: "uploadId=upload&partNumber=1", action: s3_constants.ACTION_WRITE},
|
|
{name: "multipart leading zero", method: http.MethodPut, object: "key", subresource: "uploadId=upload&partNumber=01", action: s3_constants.ACTION_WRITE},
|
|
{name: "upload id only", method: http.MethodPut, object: "key", subresource: "uploadId=upload", action: s3_constants.ACTION_WRITE, wantACL: "public-read"},
|
|
{name: "invalid part number", method: http.MethodPut, object: "key", subresource: "uploadId=upload&partNumber=abc", action: s3_constants.ACTION_WRITE, wantACL: "public-read"},
|
|
{name: "standalone acl", method: http.MethodPut, object: "key", subresource: "acl=", action: s3_constants.ACTION_WRITE_ACP},
|
|
{name: "tagging", method: http.MethodPut, object: "key", subresource: "tagging=", action: s3_constants.ACTION_WRITE},
|
|
{name: "retention", method: http.MethodPut, object: "key", subresource: "retention=", action: s3_constants.ACTION_WRITE},
|
|
{name: "other service", method: http.MethodPut, object: "key", action: "iam:CreateUser"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(tt.method, "http://s3/bucket/"+tt.object+"?x-amz-acl=public-read&"+tt.subresource, nil)
|
|
if tt.copy {
|
|
req.Header.Set("X-Amz-Copy-Source", "/source/key")
|
|
}
|
|
if tt.repeatedCopy {
|
|
req.Header.Set("X-Amz-Copy-Source", "bogus")
|
|
req.Header.Add("X-Amz-Copy-Source", "%2fsource%2fkey")
|
|
}
|
|
policyRequest, code := putObjectACLPolicyRequest(req, tt.action, "bucket", tt.object)
|
|
require.Equal(t, s3err.ErrNone, code)
|
|
require.Equal(t, tt.wantACL, policyRequest.Header.Get(s3_constants.AmzCannedAcl))
|
|
require.Empty(t, req.Header.Get(s3_constants.AmzCannedAcl), "normalization must preserve signed headers")
|
|
})
|
|
}
|
|
}
|