fix: authorize every key in a DeleteObjects batch after a bucket-policy deny

objectsAccessErrors recorded the first resource-policy Deny and returned immediately, leaving every later key in the same action subset with a nil result — which VerifyObjectsAccess caller reads as "authorized" and sends straight to the backend. An explicit bucket-policy Deny therefore let the keys after it skip authorization entirely and be deleted, including keys that same bucket policy explicitly denied.

The loop now continues rather than returning, so every denied key is settled with its own error. The identity-policy round trip the early return was saving is still skipped, but only when the bucket policy denied every key in the batch, since no identity-policy answer could change any result then. Both loops that follow skip keys already holding an error, so a resource-level explicit deny is never overwritten by an identity-policy result nor flattened to the generic AccessDenied message.
This commit is contained in:
niksis02
2026-09-01 14:40:09 +04:00
parent 7a1a3e4775
commit f567abc91c
7 changed files with 389 additions and 46 deletions
+27 -5
View File
@@ -283,15 +283,25 @@ func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOpt
errs := make([]error, len(keys))
// An explicit deny from the bucket policy wins outright, whatever the
// IAM backend is, so a request carrying one needs no identity policy at
// all — which also saves the standalone IAM service round trip. Only the
// first denied key is recorded: the request fails there regardless of
// what the rest would have evaluated to.
// IAM backend is and whatever an identity policy would have said, so
// every denied key is settled here and never revisited below. Each key
// is settled on its own: this is a partial-success API, so a deny on one
// key says nothing about the next one, which still has to be evaluated
// on its own merits.
allDenied := true
for i, rd := range resourceDecisions {
if rd.Decision == policyDecisionDeny {
errs[i] = s3err.GetExplicitDenyAccessErr(opts.Acc.Access, string(rd.Action), objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey), "a resource-based policy")
return errs, nil
continue
}
allDenied = false
}
// Nothing is left to decide, so skip the identity policy entirely —
// which also saves the standalone IAM service round trip. That shortcut
// only holds when the bucket policy denied every key; a single
// undecided key still needs the identity policy consulted for it.
if allDenied {
return errs, nil
}
pe, hasPolicyEvaluator := opts.Iam.(PolicyEvaluator)
@@ -300,6 +310,11 @@ func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOpt
// today's exact behavior and generic message, unconditionally, for
// every internal/LDAP/Vault/IPA/S3-IAM deployment.
for i, rd := range resourceDecisions {
if errs[i] != nil {
// Explicitly denied above — keep that specific message
// rather than flattening it to the generic one.
continue
}
if rd.Decision != policyDecisionAllow {
errs[i] = s3err.GetAPIError(s3err.ErrAccessDenied)
}
@@ -318,6 +333,13 @@ func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOpt
}
for i := range keys {
if errs[i] != nil {
// Explicitly denied by the bucket policy. An explicit deny is
// final, so no identity-policy result can clear it, and the
// resource-based message is the one AWS reports for it.
continue
}
resourceArn := objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey)
if identity.Decisions[i].Decision == policyDecisionDeny {
+138
View File
@@ -779,4 +779,142 @@ func TestVerifyObjectsAccess_VersionedDeleteNeedsSeparatePermission(t *testing.T
}
}
// bucketPolicyNoLockBackend serves a fixed bucket policy and answers "no
// lock configuration", so VerifyObjectsAccess' lock check is a no-op and
// only the policy half of the per-object result is under test.
type bucketPolicyNoLockBackend struct {
backend.BackendUnsupported
policy []byte
}
func (b bucketPolicyNoLockBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
return b.policy, nil
}
func (b bucketPolicyNoLockBackend) GetObjectLockConfiguration(_ context.Context, _ string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)
}
// denyProtectedPrefixBackend is a bucket policy denying s3:DeleteObject on
// one prefix and saying nothing about anything else, so a batch can mix
// explicitly denied keys with keys the bucket policy leaves undecided.
func denyProtectedPrefixBackend() bucketPolicyNoLockBackend {
return bucketPolicyNoLockBackend{policy: []byte(`{
"Statement": [{
"Effect": "Deny",
"Principal": "testuser",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::bucket/protected/*"
}]
}`)}
}
func deleteObjectIdentifiers(keys ...string) []types.ObjectIdentifier {
objects := make([]types.ObjectIdentifier, len(keys))
for i, key := range keys {
objects[i] = types.ObjectIdentifier{Key: strPtr(key)}
}
return objects
}
// TestVerifyObjectsAccess_ResourceDenyDoesNotAuthorizeLaterKeys is the
// authorization-bypass regression: a bucket-policy Deny used to end the
// whole batch's evaluation at the first denied key, leaving every later key
// with a nil result — which VerifyObjectsAccess' caller reads as
// "authorized" and sends straight to the backend. Every key must be settled
// on its own instead: the denied one explicitly, the rest by the identity
// policy, which here allows neither.
func TestVerifyObjectsAccess_ResourceDenyDoesNotAuthorizeLaterKeys(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionNoMatch)
pe.principalArn = "arn:aws:iam::000000000000:user/testuser"
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, deleteObjectIdentifiers("protected/x", "secret/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
denied := requireAccessDeniedAPIError(t, errs[0])
assert.Contains(t, denied.Description, "with an explicit deny in a resource-based policy")
later := requireAccessDeniedAPIError(t, errs[1])
assert.Contains(t, later.Description, "because no identity-based policy allows the s3:DeleteObject action")
}
assert.Len(t, pe.calls, 1, "a key the bucket policy left undecided still needs the identity policy consulted for it")
}
// TestVerifyObjectsAccess_ResourceDenyKeptOverIdentityAllow confirms an
// explicit deny still wins per key once every key is evaluated: the denied
// key keeps its resource-based denial even though the identity policy
// allows it, while the key the bucket policy said nothing about is
// authorized by that same identity Allow.
func TestVerifyObjectsAccess_ResourceDenyKeptOverIdentityAllow(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionAllow)
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, deleteObjectIdentifiers("protected/x", "allowed/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
denied := requireAccessDeniedAPIError(t, errs[0])
assert.Contains(t, denied.Description, "with an explicit deny in a resource-based policy")
assert.NoError(t, errs[1], "the identity policy's Allow stands for the key the bucket policy didn't deny")
}
}
// TestVerifyObjectsAccess_AllKeysResourceDeniedSkipsIdentityPolicy covers
// the round trip the short-circuit was there to save: it is still skipped,
// but only when the bucket policy denied every key in the batch, since then
// no identity-policy answer could change any result.
func TestVerifyObjectsAccess_AllKeysResourceDeniedSkipsIdentityPolicy(t *testing.T) {
pe := newMockPolicyEvaluator(policyDecisionAllow)
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: pe,
}, deleteObjectIdentifiers("protected/x", "protected/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
for i, e := range errs {
denied := requireAccessDeniedAPIError(t, e)
assert.Containsf(t, denied.Description, "with an explicit deny in a resource-based policy", "key %d", i)
}
}
assert.Empty(t, pe.calls, "with every key already explicitly denied there is nothing left for the identity policy to decide")
}
// TestVerifyObjectsAccess_ResourceDenyNoPolicyEvaluator covers the same
// bypass for the IAM backends with no identity-policy layer at all
// (internal, LDAP, Vault, IPA): the denied key keeps its specific message
// and every other key falls to the generic AccessDenied those backends have
// always returned — none of them silently authorized.
func TestVerifyObjectsAccess_ResourceDenyNoPolicyEvaluator(t *testing.T) {
errs, err := VerifyObjectsAccess(testFiberCtx(t), denyProtectedPrefixBackend(), AccessOptions{
Acc: Account{Access: "testuser", Role: RoleUser},
Bucket: "bucket",
AclPermission: PermissionWrite,
Iam: NewIAMServiceSingle(Account{}),
}, deleteObjectIdentifiers("protected/x", "secret/y"), BypassNone)
assert.NoError(t, err)
if assert.Len(t, errs, 2) {
denied := requireAccessDeniedAPIError(t, errs[0])
assert.Contains(t, denied.Description, "with an explicit deny in a resource-based policy")
later := requireAccessDeniedAPIError(t, errs[1])
assert.Equal(t, s3err.GetAPIError(s3err.ErrAccessDenied).Description, later.Description,
"backends with no identity-policy layer keep their generic message")
}
}
func strPtr(s string) *string { return &s }