s3api: persist ACLs on PutObject uploads (#11592)

* 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>
This commit is contained in:
zhao-ycandChris Lu authored and GitHub committed 2026-10-05 09:13:19 +08:00
1 parent 9d1c24d80d
commit 483dd4b12e
15 files changed
+1266 -48

No files matched your search

+9 -1
View File
@@ -1778,6 +1778,13 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
}
bucket, object := s3_constants.GetBucketAndObject(r)
// Verify the original signature first, then evaluate policies against the
// effective PUT ACL, including signed query parameters hoisted by presigners.
originalRequest := r
r, s3Err = putObjectACLPolicyRequest(r, action, bucket, object)
if s3Err != s3err.ErrNone {
return identity, s3Err, reqAuthType
}
prefix := s3_constants.GetPrefix(r)
// For bucket listings, use prefix for permission checking if available:
@@ -1873,7 +1880,7 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
}
}
r.Header.Set(s3_constants.AmzAccountId, identity.Account.Id)
originalRequest.Header.Set(s3_constants.AmzAccountId, identity.Account.Id)
return identity, s3err.ErrNone, reqAuthType
@@ -2558,6 +2565,7 @@ func (iam *IdentityAccessManagement) evaluateAttachedIAMPolicies(r *http.Request
Conditions: conditions,
Claims: identity.Claims,
}
evalArgs.OriginalGrantConditions = policy_engine.OriginalGrantConditionsFromRequest(r)
// Evaluate user's own policies
for _, policyName := range identity.PolicyNames {
+12
View File
@@ -756,6 +756,12 @@ func getConditionContextValue(key string, contextValues map[string][]string, obj
// objectEntry is the object's metadata from entry.Extended (can be nil)
// claims are JWT claims for jwt:* policy variables (can be nil)
func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte, claims map[string]interface{}) bool {
return evaluateConditions(conditions, contextValues, objectEntry, claims, nil)
}
// evaluateConditions supplements positive string grant conditions in explicit denies with the original complete list.
// Select values per operator so whitespace or escapes cannot change negative conditions on the same key.
func evaluateConditions(conditions PolicyConditions, contextValues map[string][]string, objectEntry map[string][]byte, claims map[string]interface{}, originalGrants map[string][]string) bool {
if len(conditions) == 0 {
return true // No conditions means always true
}
@@ -769,6 +775,12 @@ func EvaluateConditions(conditions PolicyConditions, contextValues map[string][]
for key, value := range conditionMap {
contextVals := getConditionContextValue(key, contextValues, objectEntry)
if original := originalGrants[key]; len(original) != 0 && isGrantConditionKey(key) {
switch operator {
case "StringEquals", "StringEqualsIgnoreCase", "StringLike", "ArnEquals", "ArnLike":
contextVals = append(append([]string(nil), contextVals...), original...)
}
}
// Substitute variables in expected values
expectedValues := value.Strings()
+4
View File
@@ -246,6 +246,10 @@ func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *Pol
condCtx = injectSSEForMultipart(args.Conditions, args.InheritedSSEAlgorithm)
}
match := EvaluateConditions(stmt.Statement.Condition, condCtx, args.ObjectEntry, args.Claims)
// Preserve positive string denies on the original complete list; allows, negative conditions, and variables remain canonical.
if !match && stmt.Statement.Effect == PolicyEffectDeny && len(args.OriginalGrantConditions) != 0 {
match = evaluateConditions(stmt.Statement.Condition, condCtx, args.ObjectEntry, args.Claims, args.OriginalGrantConditions)
}
if !match {
return false
}
@@ -0,0 +1,46 @@
package policy_engine
import (
"context"
"net/http"
)
// originalGrantConditionsKey prevents clients from forging original grant values through request headers.
type originalGrantConditionsKey struct{}
// isGrantConditionKey restricts original-value checks to the five upload grant condition keys.
func isGrantConditionKey(key string) bool {
switch key {
case "s3:x-amz-grant-read", "s3:x-amz-grant-write", "s3:x-amz-grant-read-acp", "s3:x-amz-grant-write-acp", "s3:x-amz-grant-full-control":
return true
}
return false
}
// cloneGrantConditions isolates inputs and results so later mutations cannot change the original complete grant representation.
func cloneGrantConditions(values map[string][]string) map[string][]string {
if len(values) == 0 {
return nil
}
cloned := make(map[string][]string, len(values))
for key, grants := range values {
if isGrantConditionKey(key) {
cloned[key] = append([]string(nil), grants...)
}
}
return cloned
}
// WithOriginalGrantConditions saves the complete list before upload normalization to supplement explicit deny checks only.
func WithOriginalGrantConditions(r *http.Request, values map[string][]string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), originalGrantConditionsKey{}, cloneGrantConditions(values)))
}
// OriginalGrantConditionsFromRequest reads the internal snapshot; other operations have no such context.
func OriginalGrantConditionsFromRequest(r *http.Request) map[string][]string {
if r == nil {
return nil
}
values, _ := r.Context().Value(originalGrantConditionsKey{}).(map[string][]string)
return cloneGrantConditions(values)
}
@@ -0,0 +1,63 @@
package policy_engine
import (
"fmt"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// TestOriginalGrantConditionsDeny verifies original positive denies without expanding allows or negative conditions.
func TestOriginalGrantConditionsDeny(t *testing.T) {
const key = "s3:x-amz-grant-read"
const canonical, original = `id="bucket-owner"`, `id = "bucket-\u006fwner"`
tests := []struct {
name, effect, conditions string
result PolicyEvaluationResult
}{
{"original exact deny", "Deny", fmt.Sprintf(`{"StringEquals":{%q:%q}}`, key, original), PolicyResultDeny},
{"original wildcard deny", "Deny", fmt.Sprintf(`{"StringLike":{%q:%q}}`, key, `id = *`), PolicyResultDeny},
{"canonical deny unchanged", "Deny", fmt.Sprintf(`{"StringEquals":{%q:%q}}`, key, canonical), PolicyResultDeny},
{"approved negative condition not denied", "Deny", fmt.Sprintf(`{"StringNotEquals":{%q:%q}}`, key, canonical), PolicyResultIndeterminate},
{"negative deny unchanged", "Deny", fmt.Sprintf(`{"StringNotEquals":{%q:%q}}`, key, `id="other"`), PolicyResultDeny},
{"original value cannot allow", "Allow", fmt.Sprintf(`{"StringEquals":{%q:%q}}`, key, original), PolicyResultIndeterminate},
{"negative allow not expanded", "Allow", fmt.Sprintf(`{"StringNotEquals":{%q:%q}}`, key, canonical), PolicyResultIndeterminate},
{"complete list allow unchanged", "Allow", fmt.Sprintf(`{"StringEquals":{%q:%q}}`, key, canonical), PolicyResultAllow},
{"positive and negative conditions evaluated separately", "Deny", fmt.Sprintf(`{"StringEquals":{%q:%q},"StringNotEquals":{%q:%q}}`, key, original, key, canonical), PolicyResultIndeterminate},
{"other conditions must still match", "Deny", fmt.Sprintf(`{"StringEquals":{%q:%q,"aws:username":"other"}}`, key, original), PolicyResultIndeterminate},
{"variables remain canonical", "Deny", fmt.Sprintf(`{"StringEquals":{%q:"${s3:x-amz-grant-read}"}}`, key), PolicyResultDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
engine := NewPolicyEngine()
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":%q,"Principal":"*","Action":"s3:PutObjectAcl","Resource":"arn:aws:s3:::bucket/object","Condition":%s}]}`, tt.effect, tt.conditions)
require.NoError(t, engine.SetBucketPolicy("bucket", policy))
req := WithOriginalGrantConditions(httptest.NewRequest("PUT", "/bucket/object", nil), map[string][]string{key: {original}})
args := &PolicyEvaluationArgs{
Action: "s3:PutObjectAcl", Resource: "arn:aws:s3:::bucket/object", Principal: "upload-writer",
Conditions: map[string][]string{key: {canonical}, "aws:username": {"upload-writer"}},
OriginalGrantConditions: OriginalGrantConditionsFromRequest(req),
}
require.Equal(t, tt.result, engine.EvaluatePolicy("bucket", args))
})
}
}
// TestOriginalGrantConditionsSnapshot verifies original values cannot be forged through headers or later mutations.
func TestOriginalGrantConditionsSnapshot(t *testing.T) {
const key = "s3:x-amz-grant-read"
values := map[string][]string{key: {`id = "bucket-owner"`}, "aws:username": {"forged"}}
original := httptest.NewRequest("PUT", "/bucket/object", nil)
original.Header.Set("X-Amz-Original-Grant-Read", `id="attacker"`)
require.Nil(t, OriginalGrantConditionsFromRequest(original))
require.Nil(t, OriginalGrantConditionsFromRequest(nil))
req := WithOriginalGrantConditions(original, values)
values[key][0] = `id="attacker"`
delete(values, key)
first := OriginalGrantConditionsFromRequest(req)
require.Equal(t, `id = "bucket-owner"`, first[key][0])
require.NotContains(t, first, "aws:username")
first[key][0] = `id="attacker"`
require.Equal(t, `id = "bucket-owner"`, OriginalGrantConditionsFromRequest(req)[key][0])
}
+3
View File
@@ -315,6 +315,9 @@ type PolicyEvaluationArgs struct {
// inherited from the CreateMultipartUpload request for UploadPart and
// UploadPartCopy actions. The empty string means no SSE was used.
InheritedSSEAlgorithm string
// Original complete grant values supplement only positive string conditions in explicit denies, never allows or negative conditions.
OriginalGrantConditions map[string][]string
}
// PolicyCache for caching compiled policies
+3 -1
View File
@@ -141,7 +141,9 @@ func resolveFromQueryParameters(query url.Values, method string, hasObject bool)
if hasObject && query.Has("uploadId") {
switch method {
case http.MethodPut:
if query.Has("partNumber") {
// Match the multipart route's [0-9]+ pattern; invalid part parameters fall through to regular uploads.
partNumber := query.Get("partNumber")
if partNumber != "" && strings.Trim(partNumber, "0123456789") == "" {
return s3_constants.S3_ACTION_UPLOAD_PART
}
case http.MethodPost:
@@ -0,0 +1,61 @@
package s3api
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/require"
)
// TestParseCustomAclHeaderList covers the wire syntax separately from account
// resolution, including quoted delimiters and rejection without partial grants.
func TestParseCustomAclHeaderList(t *testing.T) {
tests := []struct {
name, input string
values []string
invalid bool
}{
{name: "absent"},
{name: "single", input: `id="alice"`, values: []string{"alice"}},
{name: "comma without space", input: `id="alice",id="bob"`, values: []string{"alice", "bob"}},
{name: "comma with space", input: `id="alice", id="bob"`, values: []string{"alice", "bob"}},
{name: "optional whitespace", input: " id = \"alice\" ,\t id=\"bob\" ", values: []string{"alice", "bob"}},
{name: "quoted comma and equals", input: `id="a,b=c",id="bob"`, values: []string{"a,b=c", "bob"}},
{name: "escaped quote", input: `id="a\"b",id="bob"`, values: []string{`a"b`, "bob"}},
{name: "email", input: `emailAddress="a=b@example.com"`, values: []string{"a=b@example.com"}},
{name: "group", input: `uri="http://acs.amazonaws.com/groups/global/AllUsers"`, values: []string{s3_constants.GranteeGroupAllUsers}},
{name: "unknown type", input: `account="alice"`, invalid: true},
{name: "mixed unknown type", input: `id="alice",principal="bob"`, invalid: true},
{name: "empty grantee", input: `id=""`, invalid: true},
{name: "unquoted", input: `id=alice`, invalid: true},
{name: "unterminated", input: `id="alice`, invalid: true},
{name: "trailing comma", input: `id="alice",`, invalid: true},
{name: "empty element", input: `id="alice",,id="bob"`, invalid: true},
{name: "missing comma", input: `id="alice" id="bob"`, invalid: true},
{name: "invalid escape", input: `id="a\q"`, invalid: true},
{name: "whitespace only", input: " ", invalid: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
original := &s3.Grant{Permission: aws.String(s3_constants.PermissionFullControl)}
grants := []*s3.Grant{original}
code := ParseCustomAclHeader(tt.input, s3_constants.PermissionRead, &grants)
if tt.invalid {
require.Equal(t, s3err.ErrInvalidRequest, code)
require.Equal(t, []*s3.Grant{original}, grants, "invalid lists must not leave partial grants")
return
}
require.Equal(t, s3err.ErrNone, code)
require.Len(t, grants, 1+len(tt.values))
for i, value := range tt.values {
grant := grants[i+1]
actual := aws.StringValue(grant.Grantee.ID) + aws.StringValue(grant.Grantee.EmailAddress) + aws.StringValue(grant.Grantee.URI)
require.Equal(t, value, actual)
require.Equal(t, s3_constants.PermissionRead, aws.StringValue(grant.Permission))
}
})
}
}
+57 -44
View File
@@ -130,7 +130,7 @@ func ParseCustomAclHeaders(r *http.Request, grants *[]*s3.Grant) s3err.ErrorCode
customAclHeaders := []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp}
var errCode s3err.ErrorCode
for _, customAclHeader := range customAclHeaders {
headerValue := r.Header.Get(customAclHeader)
headerValue := strings.Join(r.Header.Values(customAclHeader), ",")
switch customAclHeader {
case s3_constants.AmzAclRead:
errCode = ParseCustomAclHeader(headerValue, s3_constants.PermissionRead, grants)
@@ -150,51 +150,64 @@ func ParseCustomAclHeaders(r *http.Request, grants *[]*s3.Grant) s3err.ErrorCode
return s3err.ErrNone
}
func ParseCustomAclHeader(headerValue, permission string, grants *[]*s3.Grant) s3err.ErrorCode {
if len(headerValue) > 0 {
split := strings.Split(headerValue, ", ")
for _, grantStr := range split {
kv := strings.Split(grantStr, "=")
if len(kv) != 2 {
return s3err.ErrInvalidRequest
}
switch kv[0] {
case "id":
var accountId string
_ = json.Unmarshal([]byte(kv[1]), &accountId)
*grants = append(*grants, &s3.Grant{
Grantee: &s3.Grantee{
Type: &s3_constants.GrantTypeCanonicalUser,
ID: &accountId,
},
Permission: &permission,
})
case "emailAddress":
var emailAddress string
_ = json.Unmarshal([]byte(kv[1]), &emailAddress)
*grants = append(*grants, &s3.Grant{
Grantee: &s3.Grantee{
Type: &s3_constants.GrantTypeAmazonCustomerByEmail,
EmailAddress: &emailAddress,
},
Permission: &permission,
})
case "uri":
var groupName string
_ = json.Unmarshal([]byte(kv[1]), &groupName)
*grants = append(*grants, &s3.Grant{
Grantee: &s3.Grantee{
Type: &s3_constants.GrantTypeGroup,
URI: &groupName,
},
Permission: &permission,
})
}
}
// parseAclGranteePairs decodes a comma-separated list of quoted grantees into
// key/value pairs. Decoding each value before splitting keeps commas and equals
// signs inside quotes intact.
func parseAclGranteePairs(headerValue string) (pairs [][2]string, errCode s3err.ErrorCode) {
if headerValue == "" {
return nil, s3err.ErrNone
}
return s3err.ErrNone
remaining := strings.TrimSpace(headerValue)
for {
key, encoded, ok := strings.Cut(remaining, "=")
if !ok {
return nil, s3err.ErrInvalidRequest
}
decoder := json.NewDecoder(strings.NewReader(encoded))
var value string
if decoder.Decode(&value) != nil || value == "" {
return nil, s3err.ErrInvalidRequest
}
key = strings.TrimSpace(key)
switch key {
case "id", "emailAddress", "uri":
default:
return nil, s3err.ErrInvalidRequest
}
pairs = append(pairs, [2]string{key, value})
remaining = strings.TrimSpace(encoded[decoder.InputOffset():])
if remaining == "" {
break
}
if remaining[0] != ',' {
return nil, s3err.ErrInvalidRequest
}
remaining = strings.TrimSpace(remaining[1:])
}
return pairs, s3err.ErrNone
}
func ParseCustomAclHeader(headerValue, permission string, grants *[]*s3.Grant) s3err.ErrorCode {
pairs, errCode := parseAclGranteePairs(headerValue)
if errCode != s3err.ErrNone {
return errCode
}
var parsed []*s3.Grant
for i := range pairs {
grantee := &s3.Grantee{}
switch pairs[i][0] {
case "id":
grantee.Type, grantee.ID = &s3_constants.GrantTypeCanonicalUser, &pairs[i][1]
case "emailAddress":
grantee.Type, grantee.EmailAddress = &s3_constants.GrantTypeAmazonCustomerByEmail, &pairs[i][1]
case "uri":
grantee.Type, grantee.URI = &s3_constants.GrantTypeGroup, &pairs[i][1]
}
parsed = append(parsed, &s3.Grant{Grantee: grantee, Permission: &permission})
}
// Do not leave partially parsed grants behind when any list element fails.
*grants = append(*grants, parsed...)
return s3err.ErrNone
}
func ParseCannedAclHeader(bucketOwnership, bucketOwnerId, accountId, cannedAcl string, putAcl bool) (ownerId string, grants []*s3.Grant, err s3err.ErrorCode) {
+1
View File
@@ -145,6 +145,7 @@ func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal
// Extract conditions and claims from request if available
if r != nil {
args.OriginalGrantConditions = policy_engine.OriginalGrantConditionsFromRequest(r)
args.Conditions = bpe.engine.ExtractConditionValuesFromRequest(r)
// Extract principal-related variables (aws:username, etc.) from principal ARN
+23 -2
View File
@@ -147,6 +147,13 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
return
}
var aclCode s3err.ErrorCode
r, aclCode = s3a.preparePutObjectACL(r, bucket)
if aclCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, aclCode)
return
}
objectLockEnabled, lockErr := s3a.isObjectLockEnabled(bucket)
if lockErr != nil && !errors.Is(lockErr, filer_pb.ErrNotFound) {
glog.Errorf("PutObjectHandler: failed to check object lock for bucket %s: %v", bucket, lockErr)
@@ -230,6 +237,7 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
// Set object owner for directory objects (same as regular objects)
s3a.setObjectOwnerFromRequest(r, bucket, entry)
applyPutObjectACL(r, entry)
if lockErr := s3a.extractObjectLockMetadataFromRequest(r, entry); lockErr != nil {
glog.Errorf("PutObjectHandler: failed to extract object lock metadata for %s/%s: %v", bucket, object, lockErr)
@@ -268,6 +276,13 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
}
}
var aclCode s3err.ErrorCode
r, aclCode = s3a.preparePutObjectACL(r, bucket)
if aclCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, aclCode)
return
}
versioningEnabled := (versioningState == s3_constants.VersioningEnabled)
versioningConfigured := (versioningState != "")
@@ -796,6 +811,7 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader
// Set object owner according to bucket ownership settings.
s3a.setObjectOwnerFromRequest(r, bucket, entry)
applyPutObjectACL(r, entry)
// Set version ID if present. It is later used as a filer path segment, so a
// value carrying "/", "\\" or ".." must never be stored.
@@ -1355,9 +1371,14 @@ func detectRequestedChecksumAlgorithmQ(r *http.Request, query url.Values) (Check
const defaultFileMode = uint32(0660)
// resolveFileMode determines the file permission mode for an S3 upload.
// Priority: per-object X-Amz-Acl header > server default > defaultFileMode.
// Priority: validated PUT ACL > X-Amz-Acl header > server default > defaultFileMode.
func (s3a *S3ApiServer) resolveFileMode(r *http.Request) uint32 {
if cannedAcl := r.Header.Get(s3_constants.AmzCannedAcl); cannedAcl != "" {
cannedAcl := r.Header.Get(s3_constants.AmzCannedAcl)
if metadata, ok := r.Context().Value(putObjectACLContextKey{}).(putObjectACLMetadata); ok {
// Signed query ACLs must resolve identically to signed ACL headers.
cannedAcl = metadata.canned
}
if cannedAcl != "" {
switch cannedAcl {
case s3_constants.CannedAclPublicRead, s3_constants.CannedAclAuthenticatedRead,
s3_constants.CannedAclBucketOwnerRead:
+265
View File
@@ -0,0 +1,265 @@
package s3api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/aws/aws-sdk-go/service/s3"
"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/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// putObjectACLContextKey carries validated ACL metadata to every PutObject write
// path without exposing an internal header that a client could forge.
type putObjectACLContextKey struct{}
type putObjectACLMetadata struct {
extended map[string][]byte
canned string
}
// putObjectACLValue ignores unsigned V2 query ACLs and rejects ambiguity.
// Headers stay untouched because authentication verifies the original request.
func putObjectACLValue(r *http.Request, query url.Values, header string) (string, s3err.ErrorCode) {
// Preserve SigV2's header-only ACL behavior: arbitrary query parameters
// are not in its canonical resource and must have no effect on grants.
switch getRequestAuthType(r) {
case authTypeSignedV2, authTypePresignedV2:
query = nil
}
var queryValues []string
queryPresent := false
for key, values := range query {
if strings.EqualFold(key, header) {
queryPresent = true
queryValues = append(queryValues, values...)
}
}
if queryPresent {
if len(queryValues) != 1 {
// V4 sorts duplicate values when signing. Choosing the first value
// would let reordering change the effective ACL without resigning.
return "", s3err.ErrInvalidRequest
}
}
values := r.Header.Values(header)
if header == s3_constants.AmzCannedAcl && len(values) > 1 {
return "", s3err.ErrInvalidRequest
}
value := strings.Join(values, ",")
if queryPresent {
if len(values) > 0 && value != queryValues[0] {
return "", s3err.ErrInvalidRequest
}
value = queryValues[0]
}
return value, s3err.ErrNone
}
// putObjectACLPolicyRequest exposes effective PUT ACLs to policy conditions only
// after authentication. Other operations keep their original request semantics.
func putObjectACLPolicyRequest(r *http.Request, action Action, bucket, object string) (*http.Request, s3err.ErrorCode) {
// Copy routes match any repeated header value, so checking only the first line can misclassify a copy as a regular upload.
copyRequest := false
for _, copySource := range r.Header.Values("X-Amz-Copy-Source") {
if strings.Contains(copySource, "/") || strings.Contains(strings.ToLower(copySource), "%2f") {
copyRequest = true
break
}
}
if (action != s3_constants.ACTION_WRITE && action != s3_constants.ACTION_WRITE_ACP) ||
r.Method != http.MethodPut || object == "" || object == "/" ||
copyRequest ||
ResolveS3Action(r, string(s3_constants.ACTION_WRITE), bucket, object) != s3_constants.S3_ACTION_PUT_OBJECT {
return r, s3err.ErrNone
}
// Rechecks reuse the normalized internal request, preserving signed original values without false query conflicts.
if len(policy_engine.OriginalGrantConditionsFromRequest(r)) != 0 {
return r, s3err.ErrNone
}
policyRequest := r.Clone(r.Context())
query := parseRequestQuery(r)
originalGrants := make(map[string][]string)
for _, header := range []string{s3_constants.AmzCannedAcl, s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
value, code := putObjectACLValue(r, query, header)
if code != s3err.ErrNone {
return r, code
}
if value == "" {
continue
}
if header == s3_constants.AmzCannedAcl {
policyRequest.Header.Set(header, value)
continue
}
// Preserve only the complete effective list for original-string denies, not individual header lines as separate lists.
originalGrants["s3:"+strings.ToLower(header)] = []string{value}
// Policy conditions see the canonical grant list: one comma-separated
// value covering every persisted grantee, identical for a single line,
// repeated lines, or a signed query parameter. Sneaking an extra grantee
// past a StringEquals allow or a StringNotEquals allowlist deny requires
// changing this value, which a signed request cannot do.
pairs, pairCode := parseAclGranteePairs(value)
if pairCode != s3err.ErrNone {
return r, pairCode
}
var tokens []string
for _, pair := range pairs {
// Grant conditions use JSON quoting without HTML escaping, so valid
// literal characters in an account or email still match the policy.
var encoded strings.Builder
encoder := json.NewEncoder(&encoded)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(pair[1]); err != nil {
return r, s3err.ErrInvalidRequest
}
tokens = append(tokens, pair[0]+"="+strings.TrimSuffix(encoded.String(), "\n"))
}
policyRequest.Header.Set(header, strings.Join(tokens, ","))
}
if len(originalGrants) != 0 {
policyRequest = policy_engine.WithOriginalGrantConditions(policyRequest, originalGrants)
}
return policyRequest, s3err.ErrNone
}
// preparePutObjectACL validates and authorizes ACLs before the upload body is
// consumed. The resulting metadata is committed in the same entry as the object.
func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*http.Request, s3err.ErrorCode) {
metadata, code := s3a.getBucketConfig(bucket)
if code != s3err.ErrNone {
return r, code
}
if metadata == nil || s3a.iam == nil {
return r, s3err.ErrInternalError
}
// Presigners can hoist ACL headers into the signed query string. Normalize a
// separate request for parsing, preserving the original for signature checks.
aclRequest := r.Clone(r.Context())
query := parseRequestQuery(r)
custom := false
for _, header := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
value, code := putObjectACLValue(r, query, header)
if code != s3err.ErrNone {
return r, code
}
if value != "" {
custom = true
aclRequest.Header.Set(header, value)
}
}
canned, code := putObjectACLValue(r, query, s3_constants.AmzCannedAcl)
if code != s3err.ErrNone {
return r, code
}
aclRequest.Header.Set(s3_constants.AmzCannedAcl, canned)
explicit := canned != "" || custom
accountID := r.Header.Get(s3_constants.AmzAccountId)
if !s3a.iam.isEnabled() {
accountID = AccountAdmin.Id
} else if explicit {
// Setting an ACL during PutObject also requires s3:PutObjectAcl. Use the
// unified authorization path so bucket-policy allows and explicit denies
// retain the same semantics as standalone ACL requests.
identity, authCode := s3a.iam.authRequest(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
if authCode != s3err.ErrNone {
return r, authCode
}
if identity == nil || identity.Account == nil {
return r, s3err.ErrAccessDenied
}
accountID = identity.Account.Id
}
if explicit && !s3a.iam.isEnabled() {
_, object := s3_constants.GetBucketAndObject(r)
policyRequest, policyCode := putObjectACLPolicyRequest(r, s3_constants.ACTION_WRITE, bucket, object)
if policyCode != s3err.ErrNone {
return r, policyCode
}
for _, action := range []Action{s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP} {
if policyCode, _ := s3a.checkPolicyWithEntry(policyRequest, bucket, object, string(action), "", nil); policyCode != s3err.ErrNone {
return r, policyCode
}
}
}
if accountID == "" {
return r, s3err.ErrAccessDenied
}
if canned != "" && custom {
return r, s3err.ErrInvalidRequest
}
bucketOwner := metadata.Owner
if bucketOwner == "" {
// Buckets created outside S3 can have no recorded owner, matching the
// bucket registry's existing admin fallback for these entries.
bucketOwner = AccountAdmin.Id
}
ownership := s3_constants.EffectiveOwnership(metadata.Ownership)
if ownership == s3_constants.OwnershipBucketOwnerEnforced {
if metadata.Ownership == s3_constants.OwnershipBucketOwnerEnforced {
// Keep legacy buckets without recorded ownership controls accepting
// ACLs; only an explicitly configured enforced control disables them.
if custom || (canned != "" && canned != s3_constants.CannedAclBucketOwnerFullControl) {
return r, s3err.ErrAccessControlListNotSupported
}
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
}
accountID = bucketOwner
}
if aclRequest.Header.Get(s3_constants.AmzCannedAcl) == "" && !custom {
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
}
// Canned grants contain only authenticated writer and recorded bucket-owner
// IDs. Dynamic IAM/JWT accounts need not exist in the static account directory.
// Client-supplied custom grantees must still pass directory validation.
owner, grants, code := ParseAclHeaders(aclRequest, ownership, bucketOwner, accountID, false)
if code == s3err.ErrNone && custom {
grants, code = ValidateAndTransferGrants(s3a.iam, grants)
}
if code != s3err.ErrNone {
return r, code
}
if custom {
// Custom upload grants supplement the owner's default full control.
// Check after email resolution to avoid duplicating an explicit owner
// grant; the authenticated owner need not be in the static directory.
ownerFullControl := false
for _, grant := range grants {
if grant.Grantee != nil && grant.Grantee.Type != nil &&
*grant.Grantee.Type == s3_constants.GrantTypeCanonicalUser &&
grant.Grantee.ID != nil && *grant.Grantee.ID == owner &&
grant.Permission != nil && *grant.Permission == s3_constants.PermissionFullControl {
ownerFullControl = true
break
}
}
if !ownerFullControl {
grants = append(grants, &s3.Grant{
Grantee: &s3.Grantee{Type: &s3_constants.GrantTypeCanonicalUser, ID: &owner},
Permission: &s3_constants.PermissionFullControl,
})
}
}
entry := &filer_pb.Entry{}
if code = AssembleEntryWithAcp(entry, owner, grants); code != s3err.ErrNone {
return r, code
}
prepared := putObjectACLMetadata{extended: entry.Extended, canned: canned}
return r.WithContext(context.WithValue(r.Context(), putObjectACLContextKey{}, prepared)), s3err.ErrNone
}
// applyPutObjectACL adds prevalidated ownership and grants before CreateEntry.
// Multipart parts and POST form uploads do not carry this PutObject context.
func applyPutObjectACL(r *http.Request, entry *filer_pb.Entry) {
metadata, _ := r.Context().Value(putObjectACLContextKey{}).(putObjectACLMetadata)
for key, value := range metadata.extended {
entry.Extended[key] = value
}
}
+706
View File
@@ -0,0 +1,706 @@
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")
})
}
}
+7
View File
@@ -608,6 +608,13 @@ func (s3a *S3ApiServer) checkPolicyWithEntry(r *http.Request, bucket, object, ac
return s3err.ErrNone, false
}
// Upload handler rechecks use the same effective ACL conditions as authentication without changing the signed request.
policyRequest, policyCode := putObjectACLPolicyRequest(r, Action(action), bucket, object)
if policyCode != s3err.ErrNone {
return policyCode, true
}
r = policyRequest
identityRaw := GetIdentityFromContext(r)
var identity *Identity
if identityRaw != nil {
+6
View File
@@ -171,6 +171,7 @@ const (
ErrInvalidRenameSource
ErrRenameDestinationSameAsSource
ErrIdempotentParameterMismatch
ErrAccessControlListNotSupported
)
// Error message constants for checksum validation
@@ -565,6 +566,11 @@ var errorCodeResponse = map[ErrorCode]APIError{
Description: "Invalid Request",
HTTPStatusCode: http.StatusBadRequest,
},
ErrAccessControlListNotSupported: {
Code: "AccessControlListNotSupported",
Description: "The bucket does not allow ACLs",
HTTPStatusCode: http.StatusBadRequest,
},
ErrInvalidRange: {
Code: "InvalidRange",
Description: "The requested range is not satisfiable",