mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 07:54:14 +00:00
feat: add s3:if-match and s3:if-none-match policy condition keys
Closes #2294 Support AWS's conditional-write condition keys in both S3 bucket policies and IAM identity policies, so a policy can require that an upload or delete carry an `If-Match`/`If-None-Match` precondition. `requestConditionContext` now populates both keys from the request headers, which covers both policy types at once: the same map is evaluated in-process for bucket policies and shipped to the IAM service for identity policies. Only the three requests whose preconditions the gateway actually enforces contribute - PutObject, CompleteMultipartUpload and DeleteObject. Copies, form uploads, DeleteObjects batches, upload parts and the sub-resource writes all ignore the headers, and a policy must never grant on a precondition that won't be checked; reads are excluded for the same reason, since GET and HEAD take these headers as ordinary HTTP cache preconditions. The value is the ETag with its surrounding quotes stripped, matching what the gateway enforces against. Bucket policies validate condition keys against a fixed catalogue at `PutBucketPolicy` time, so both keys are added there with the action sets AWS accepts: `s3:if-match` on `s3:PutObject` and `s3:DeleteObject`, `s3:if-none-match` on `s3:PutObject` alone. Identity policies validate only the operator vocabulary, matching AWS, so they need no change.
This commit is contained in:
@@ -116,7 +116,7 @@ func VerifyAccess(ctx fiber.Ctx, be backend.Backend, opts AccessOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
errs, err := objectsAccessErrors(ctx.RequestCtx(), be, opts, []string{opts.Object}, requestConditionContext(ctx))
|
||||
errs, err := objectsAccessErrors(ctx.RequestCtx(), be, opts, []string{opts.Object}, requestConditionContext(ctx, opts.Actions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,7 +163,9 @@ func VerifyObjectsAccess(ctx fiber.Ctx, be backend.Backend, opts AccessOptions,
|
||||
}
|
||||
|
||||
rctx := ctx.RequestCtx()
|
||||
condCtx := requestConditionContext(ctx)
|
||||
// A DeleteObjects batch reads no If-Match/If-None-Match, so no
|
||||
// conditional-write key applies to any object in it.
|
||||
condCtx := requestConditionContext(ctx, nil)
|
||||
|
||||
keys := make([]string, len(objects))
|
||||
for i, obj := range objects {
|
||||
@@ -528,7 +530,7 @@ func VerifyPublicAccess(ctx fiber.Ctx, be backend.Backend, action Action, permis
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
err = VerifyPublicBucketPolicy(policy, bucket, object, requestConditionContext(ctx), be.NormalizeObjectKey, action)
|
||||
err = VerifyPublicBucketPolicy(policy, bucket, object, requestConditionContext(ctx, []Action{action}), be.NormalizeObjectKey, action)
|
||||
if errors.Is(err, errExplicitDeny) {
|
||||
// Explicit public-policy Deny has higher precedence than any
|
||||
// public ACL grant, so do not continue to ACL fallback.
|
||||
@@ -619,7 +621,7 @@ func verifyIdentityOnlyAccess(ctx fiber.Ctx, pe PolicyEvaluator, acc Account, ac
|
||||
Acc: acc,
|
||||
Bucket: resource,
|
||||
Actions: []Action{action},
|
||||
}, []string{""}, nil, requestConditionContext(ctx))
|
||||
}, []string{""}, nil, requestConditionContext(ctx, []Action{action}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -69,6 +69,28 @@ func isVersionedAction(a Action) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// isConditionalWriteAction is s3:if-match's applicable-action set:
|
||||
// s3:PutObject — which also covers CompleteMultipartUpload, authorized as
|
||||
// s3:PutObject — and s3:DeleteObject, S3's conditional delete. Note that
|
||||
// s3:DeleteObjectVersion is not in the set: a versioned delete names the
|
||||
// version to remove rather than overwriting the current one.
|
||||
//
|
||||
// addConditionalWriteKeys gates the runtime key on the same predicate, so
|
||||
// narrowing or widening this set moves both ends at once.
|
||||
func isConditionalWriteAction(a Action) bool {
|
||||
return a == PutObjectAction || a == DeleteObjectAction
|
||||
}
|
||||
|
||||
// isConditionalCreateAction is s3:if-none-match's applicable-action set:
|
||||
// s3:PutObject alone. If-None-Match asserts the object doesn't exist yet,
|
||||
// which only an upload can require — S3 rejects the header on DeleteObject.
|
||||
//
|
||||
// addConditionalWriteKeys gates the runtime key on the same predicate, so
|
||||
// narrowing or widening this set moves both ends at once.
|
||||
func isConditionalCreateAction(a Action) bool {
|
||||
return a == PutObjectAction
|
||||
}
|
||||
|
||||
// bucketPolicyConditionKeys is the fixed catalogue of condition keys this
|
||||
// gateway's S3 bucket-policy Condition support recognizes, each mapped to
|
||||
// the actions it may be used with. Keys are looked up case-insensitively
|
||||
@@ -81,10 +103,8 @@ func isVersionedAction(a Action) bool {
|
||||
// s3:RequestObjectTagKeys), object-lock keys, s3:x-amz-server-side-encryption
|
||||
// (the gateway never reads that header, so enforcing it would be
|
||||
// misleading), and aws:MultiFactorAuthAge (no MFA concept here) are out of
|
||||
// scope. A Condition naming one of those is still accepted at write time —
|
||||
// the key just never appears in the runtime context, so any Condition
|
||||
// depending on it simply never matches, the same as any other key this
|
||||
// package doesn't populate.
|
||||
// scope: a Condition naming one of those is rejected at write time, the
|
||||
// same as any other key absent from this catalogue.
|
||||
var bucketPolicyConditionKeys = map[string]conditionKeyRule{
|
||||
// Generic keys: AWS accepts these with any action.
|
||||
"aws:sourceip": {appliesTo: anyAction, ipSemantic: true},
|
||||
@@ -104,6 +124,11 @@ var bucketPolicyConditionKeys = map[string]conditionKeyRule{
|
||||
"s3:max-keys": {appliesTo: isListAction},
|
||||
"s3:x-amz-acl": {appliesTo: isAclPutAction},
|
||||
"s3:versionid": {appliesTo: isVersionedAction},
|
||||
|
||||
// Conditional-write keys, carrying the request's If-Match /
|
||||
// If-None-Match header value.
|
||||
"s3:if-match": {appliesTo: isConditionalWriteAction},
|
||||
"s3:if-none-match": {appliesTo: isConditionalCreateAction},
|
||||
}
|
||||
|
||||
// lookupConditionKeyRule finds key's rule case-insensitively.
|
||||
|
||||
@@ -137,6 +137,70 @@ func TestValidateBucketPolicyCondition(t *testing.T) {
|
||||
raw: `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`,
|
||||
actions: actionSet(AllActions),
|
||||
},
|
||||
{
|
||||
name: "s3:if-match accepted for PutObject",
|
||||
raw: `{"StringEquals":{"s3:if-match":"abc123"}}`,
|
||||
actions: actionSet(PutObjectAction),
|
||||
},
|
||||
{
|
||||
// S3's conditional delete takes If-Match, so the key applies
|
||||
// to DeleteObject as well as PutObject.
|
||||
name: "s3:if-match accepted for DeleteObject",
|
||||
raw: `{"StringEquals":{"s3:if-match":"abc123"}}`,
|
||||
actions: actionSet(DeleteObjectAction),
|
||||
},
|
||||
{
|
||||
name: "s3:if-match accepted for PutObject and DeleteObject together",
|
||||
raw: `{"StringEquals":{"s3:if-match":"abc123"}}`,
|
||||
actions: actionSet(PutObjectAction, DeleteObjectAction),
|
||||
},
|
||||
{
|
||||
name: "s3:if-match rejected for GetObject",
|
||||
raw: `{"StringEquals":{"s3:if-match":"abc123"}}`,
|
||||
actions: actionSet(GetObjectAction),
|
||||
wantErr: policyErrConditionActionMismatch,
|
||||
},
|
||||
{
|
||||
// A versioned delete names the version to remove and takes no
|
||||
// If-Match, so the key doesn't extend to it.
|
||||
name: "s3:if-match rejected for DeleteObjectVersion",
|
||||
raw: `{"StringEquals":{"s3:if-match":"abc123"}}`,
|
||||
actions: actionSet(DeleteObjectVersionAction),
|
||||
wantErr: policyErrConditionActionMismatch,
|
||||
},
|
||||
{
|
||||
name: "s3:if-none-match accepted for PutObject",
|
||||
raw: `{"Null":{"s3:if-none-match":"false"}}`,
|
||||
actions: actionSet(PutObjectAction),
|
||||
},
|
||||
{
|
||||
// If-None-Match asserts the object doesn't exist yet, which
|
||||
// only an upload can require - unlike s3:if-match, DeleteObject
|
||||
// doesn't take it.
|
||||
name: "s3:if-none-match rejected for DeleteObject",
|
||||
raw: `{"Null":{"s3:if-none-match":"false"}}`,
|
||||
actions: actionSet(DeleteObjectAction),
|
||||
wantErr: policyErrConditionActionMismatch,
|
||||
},
|
||||
{
|
||||
name: "s3:if-none-match rejected for PutObject and DeleteObject together",
|
||||
raw: `{"Null":{"s3:if-none-match":"false"}}`,
|
||||
actions: actionSet(PutObjectAction, DeleteObjectAction),
|
||||
wantErr: policyErrConditionActionMismatch,
|
||||
},
|
||||
{
|
||||
name: "conditional-write keys recognized case-insensitively",
|
||||
raw: `{"StringEquals":{"S3:IF-MATCH":"abc123"},"Null":{"s3:If-None-Match":"false"}}`,
|
||||
actions: actionSet(PutObjectAction),
|
||||
},
|
||||
{
|
||||
// The hyphens and the s3: prefix are part of the key name;
|
||||
// dropping either names a key that doesn't exist.
|
||||
name: "s3:ifmatch is not a recognized key",
|
||||
raw: `{"StringEquals":{"s3:ifmatch":"abc123"}}`,
|
||||
actions: actionSet(PutObjectAction),
|
||||
wantErr: policyErrInvalidConditionKey,
|
||||
},
|
||||
{
|
||||
name: "aws:SourceIp with a valid CIDR",
|
||||
raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`,
|
||||
|
||||
+117
-1
@@ -15,6 +15,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -28,7 +29,11 @@ import (
|
||||
// aws:username, aws:PrincipalTag/*, …) are deliberately absent: the S3
|
||||
// gateway has no way to know them, so the IAM service fills them in itself
|
||||
// when it evaluates an identity policy.
|
||||
func requestConditionContext(ctx fiber.Ctx) map[string][]string {
|
||||
//
|
||||
// actions is the action set this request is being authorized under, used by
|
||||
// the keys whose applicability AWS defines per action. Pass nil where no
|
||||
// such key can apply.
|
||||
func requestConditionContext(ctx fiber.Ctx, actions []Action) map[string][]string {
|
||||
now := time.Now().UTC()
|
||||
condCtx := map[string][]string{
|
||||
"aws:CurrentTime": {now.Format(time.RFC3339)},
|
||||
@@ -63,6 +68,117 @@ func requestConditionContext(ctx fiber.Ctx) map[string][]string {
|
||||
if versionID := ctx.Query("versionId"); versionID != "" {
|
||||
condCtx["s3:VersionId"] = []string{versionID}
|
||||
}
|
||||
addConditionalWriteKeys(ctx, actions, condCtx)
|
||||
|
||||
return condCtx
|
||||
}
|
||||
|
||||
// addConditionalWriteKeys populates s3:if-match and s3:if-none-match from
|
||||
// the request's If-Match/If-None-Match headers, each only on a request
|
||||
// whose action the key applies to. The applicability rules are the ones
|
||||
// PutBucketPolicy validates a Condition against, so a key can never reach
|
||||
// the request context on an action a policy isn't allowed to name it on.
|
||||
// The value is the header with its surrounding ETag quotes removed, so a
|
||||
// policy compares against the bare ETag whichever form the client sent.
|
||||
func addConditionalWriteKeys(ctx fiber.Ctx, actions []Action, condCtx map[string][]string) {
|
||||
action := conditionalWriteAction(ctx, actions)
|
||||
if isConditionalWriteAction(action) {
|
||||
if ifMatch := trimETagQuotes(ctx.Get("If-Match")); ifMatch != "" {
|
||||
condCtx["s3:if-match"] = []string{ifMatch}
|
||||
}
|
||||
}
|
||||
if isConditionalCreateAction(action) {
|
||||
if ifNoneMatch := trimETagQuotes(ctx.Get("If-None-Match")); ifNoneMatch != "" {
|
||||
condCtx["s3:if-none-match"] = []string{ifNoneMatch}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nonConditionalWriteSubresources names the query parameters that route an
|
||||
// object PUT or DELETE to a handler other than PutObject/DeleteObject:
|
||||
// tagging, retention, legal-hold and ACL writes, plus UploadPart and
|
||||
// AbortMultipartUpload. UploadPart and UploadPartCopy are authorized as
|
||||
// s3:PutObject just like PutObject itself, so only the route tells them
|
||||
// apart.
|
||||
var nonConditionalWriteSubresources = []string{"acl", "tagging", "retention", "legal-hold", "uploadId"}
|
||||
|
||||
// conditionalWriteAction reports the action ctx is authorized under, for
|
||||
// the requests whose If-Match/If-None-Match the gateway enforces: PutObject
|
||||
// and CompleteMultipartUpload, both authorized as s3:PutObject, and
|
||||
// DeleteObject, which a versionId turns into s3:DeleteObjectVersion exactly
|
||||
// as the handler does. It returns the empty action for everything else.
|
||||
//
|
||||
// Everything else ignores those headers, and a policy must never grant on a
|
||||
// precondition that won't be checked — otherwise a form upload, a
|
||||
// DeleteObjects batch, a copy or an upload part could satisfy a statement
|
||||
// demanding a conditional write by sending a header that changes nothing.
|
||||
// Reads are the same case: GET and HEAD take these headers as ordinary HTTP
|
||||
// cache preconditions. Excluding a request leaves both keys absent, which
|
||||
// denies it under such a policy rather than letting it through.
|
||||
//
|
||||
// The request shape alone doesn't identify an object write: a bucket
|
||||
// sub-resource write is a PUT or DELETE carrying none of the object
|
||||
// sub-resources, so it has to be recognized by what it is authorized as.
|
||||
// actions is that set, and the shape's action must be in it — the two
|
||||
// disagree exactly when the request routes somewhere else, as
|
||||
// PutBucketVersioning, CreateBucket and DeleteBucket all do.
|
||||
func conditionalWriteAction(ctx fiber.Ctx, actions []Action) Action {
|
||||
action := conditionalWriteRouteAction(ctx)
|
||||
if action == "" || !slices.Contains(actions, action) {
|
||||
return ""
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
// conditionalWriteRouteAction is conditionalWriteAction's request-shape
|
||||
// half: the action this method, query and copy-source header would route
|
||||
// to, before checking what the request is actually authorized as.
|
||||
func conditionalWriteRouteAction(ctx fiber.Ctx) Action {
|
||||
// A copy carries its preconditions in the X-Amz-Copy-Source-If-*
|
||||
// headers, which name the source object and populate neither key. Both
|
||||
// a copy and a plain upload are authorized as s3:PutObject, so only
|
||||
// this header tells them apart.
|
||||
if ctx.Get("X-Amz-Copy-Source") != "" {
|
||||
return ""
|
||||
}
|
||||
query := ctx.Request().URI().QueryArgs()
|
||||
switch string(ctx.Request().Header.Method()) {
|
||||
case fiber.MethodPut:
|
||||
if slices.ContainsFunc(nonConditionalWriteSubresources, query.Has) {
|
||||
return ""
|
||||
}
|
||||
return PutObjectAction
|
||||
case fiber.MethodDelete:
|
||||
if slices.ContainsFunc(nonConditionalWriteSubresources, query.Has) {
|
||||
return ""
|
||||
}
|
||||
// A delete naming a version removes that version rather than
|
||||
// overwriting the current one, so it is authorized as
|
||||
// s3:DeleteObjectVersion — an action neither key applies to. The
|
||||
// gateway still enforces the precondition against the named
|
||||
// version; a policy simply has no vocabulary to require it there,
|
||||
// and the key stays absent so such a statement denies instead.
|
||||
if query.Has("versionId") {
|
||||
return DeleteObjectVersionAction
|
||||
}
|
||||
return DeleteObjectAction
|
||||
case fiber.MethodPost:
|
||||
// CompleteMultipartUpload is the only POST that enforces them.
|
||||
if query.Has("uploadId") {
|
||||
return PutObjectAction
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// trimETagQuotes strips one leading and one trailing double quote from an
|
||||
// ETag-valued header, leaving any other value (notably If-None-Match's "*")
|
||||
// untouched.
|
||||
func trimETagQuotes(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestRequestConditionContextConditionalWriteKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
ifMatch string
|
||||
ifNoneMatch string
|
||||
copySource string
|
||||
// uri defaults to an object path when empty.
|
||||
uri string
|
||||
query string
|
||||
actions []Action
|
||||
want map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "no conditional headers",
|
||||
method: fiber.MethodPut,
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "If-Match quotes are stripped",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{"s3:if-match": {"abc123"}},
|
||||
},
|
||||
{
|
||||
// Both wire forms land on the same context value, so a policy
|
||||
// always compares against the bare ETag.
|
||||
name: "unquoted If-Match is taken as-is",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: "abc123",
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{"s3:if-match": {"abc123"}},
|
||||
},
|
||||
{
|
||||
// A multi-ETag If-Match is one value, not a list: the gateway
|
||||
// compares the whole header against the object's ETag, and the
|
||||
// key carries exactly what that comparison sees.
|
||||
name: "a multi-ETag If-Match is one value",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123", "def456"`,
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{"s3:if-match": {`abc123", "def456`}},
|
||||
},
|
||||
{
|
||||
name: "If-None-Match wildcard",
|
||||
method: fiber.MethodPut,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{"s3:if-none-match": {"*"}},
|
||||
},
|
||||
{
|
||||
name: "both headers on one request",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{
|
||||
"s3:if-match": {"abc123"},
|
||||
"s3:if-none-match": {"*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
// An upload carrying tagging or lock headers is authorized
|
||||
// under those actions too; s3:PutObject is still among them.
|
||||
name: "an upload authorized under several actions",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutObjectAction, PutObjectTaggingAction, PutObjectRetentionAction},
|
||||
want: map[string][]string{"s3:if-match": {"abc123"}},
|
||||
},
|
||||
{
|
||||
name: "CompleteMultipartUpload populates the keys",
|
||||
method: fiber.MethodPost,
|
||||
query: "uploadId=abc",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{"s3:if-match": {"abc123"}},
|
||||
},
|
||||
{
|
||||
name: "DeleteObject populates s3:if-match",
|
||||
method: fiber.MethodDelete,
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{DeleteObjectAction},
|
||||
want: map[string][]string{"s3:if-match": {"abc123"}},
|
||||
},
|
||||
{
|
||||
// A delete is authorized as s3:DeleteObject, which
|
||||
// s3:if-none-match doesn't apply to — DeleteObject reads no
|
||||
// If-None-Match, so the header changes nothing.
|
||||
name: "a delete populates no s3:if-none-match",
|
||||
method: fiber.MethodDelete,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{DeleteObjectAction},
|
||||
want: map[string][]string{"s3:if-match": {"abc123"}},
|
||||
},
|
||||
{
|
||||
// A versioned delete is authorized as s3:DeleteObjectVersion,
|
||||
// which neither key applies to, even though the gateway does
|
||||
// check the precondition against the named version.
|
||||
name: "a versioned delete populates neither key",
|
||||
method: fiber.MethodDelete,
|
||||
query: "versionId=v1",
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{DeleteObjectVersionAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
// A bucket sub-resource write is a bare PUT carrying none of
|
||||
// the object sub-resources, so only the action it authorizes
|
||||
// under separates it from an upload.
|
||||
name: "PutBucketVersioning populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
uri: "/bucket",
|
||||
query: "versioning=",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutBucketVersioningAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "PutBucketPolicy populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
uri: "/bucket",
|
||||
query: "policy=",
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{PutBucketPolicyAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "CreateBucket populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
uri: "/bucket",
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{CreateBucketAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "DeleteBucket populates neither key",
|
||||
method: fiber.MethodDelete,
|
||||
uri: "/bucket",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{DeleteBucketAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
// The object-lock paths authorize s3:BypassGovernanceRetention,
|
||||
// an action neither key applies to, on the very requests that
|
||||
// do carry a precondition.
|
||||
name: "a governance bypass populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{BypassGovernanceRetentionAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "no actions populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
// A form upload and a DeleteObjects batch are POSTs that never
|
||||
// look at the headers.
|
||||
name: "a form upload populates neither key",
|
||||
method: fiber.MethodPost,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "a DeleteObjects batch populates neither key",
|
||||
method: fiber.MethodPost,
|
||||
uri: "/bucket",
|
||||
query: "delete=",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{DeleteObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "an upload part populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
query: "partNumber=1&uploadId=abc",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "a tagging write populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
query: "tagging=",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutObjectTaggingAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "an ACL write populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
query: "acl=",
|
||||
ifMatch: `"abc123"`,
|
||||
actions: []Action{PutObjectAclAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
// On a read the same headers are ordinary HTTP cache
|
||||
// preconditions, not conditional writes.
|
||||
name: "GET populates neither key",
|
||||
method: fiber.MethodGet,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: `"abc123"`,
|
||||
actions: []Action{GetObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
name: "HEAD populates neither key",
|
||||
method: fiber.MethodHead,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: `"abc123"`,
|
||||
actions: []Action{GetObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
{
|
||||
// A copy takes its preconditions from the
|
||||
// X-Amz-Copy-Source-If-* headers, so a plain one is ignored.
|
||||
// It is authorized as s3:PutObject like any other upload, so
|
||||
// only the copy-source header separates the two.
|
||||
name: "a copy populates neither key",
|
||||
method: fiber.MethodPut,
|
||||
ifMatch: `"abc123"`,
|
||||
ifNoneMatch: "*",
|
||||
copySource: "/src-bucket/src-key",
|
||||
actions: []Action{PutObjectAction},
|
||||
want: map[string][]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
fctx := &fasthttp.RequestCtx{}
|
||||
fctx.Request.Header.SetMethod(tt.method)
|
||||
uri := tt.uri
|
||||
if uri == "" {
|
||||
uri = "/bucket/object"
|
||||
}
|
||||
if tt.query != "" {
|
||||
uri += "?" + tt.query
|
||||
}
|
||||
fctx.Request.SetRequestURI(uri)
|
||||
if tt.ifMatch != "" {
|
||||
fctx.Request.Header.Set("If-Match", tt.ifMatch)
|
||||
}
|
||||
if tt.ifNoneMatch != "" {
|
||||
fctx.Request.Header.Set("If-None-Match", tt.ifNoneMatch)
|
||||
}
|
||||
if tt.copySource != "" {
|
||||
fctx.Request.Header.Set("X-Amz-Copy-Source", tt.copySource)
|
||||
}
|
||||
|
||||
ctx := app.AcquireCtx(fctx)
|
||||
defer app.ReleaseCtx(ctx)
|
||||
|
||||
condCtx := requestConditionContext(ctx, tt.actions)
|
||||
for _, key := range []string{"s3:if-match", "s3:if-none-match"} {
|
||||
if want, ok := tt.want[key]; ok {
|
||||
assert.Equal(t, want, condCtx[key])
|
||||
continue
|
||||
}
|
||||
assert.NotContains(t, condCtx, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -234,7 +234,7 @@ func IsObjectLockRetentionPutAllowed(ctx fiber.Ctx, be backend.Backend, iam IAMS
|
||||
// or switching it to COMPLIANCE — with the bypass header. That needs
|
||||
// s3:BypassGovernanceRetention, via the bucket policy and/or (when
|
||||
// configured) the IAM identity policy.
|
||||
if err := verifyBypassGovernancePermission(ctx.RequestCtx(), be, iam, acc, bucket, object, BypassRequested, false, requestConditionContext(ctx)); err != nil {
|
||||
if err := verifyBypassGovernancePermission(ctx.RequestCtx(), be, iam, acc, bucket, object, BypassRequested, false, requestConditionContext(ctx, []Action{BypassGovernanceRetentionAction})); err != nil {
|
||||
debuglogger.Logf("the user is missing 's3:BypassGovernanceRetention' permission: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func CheckObjectAccess(ctx fiber.Ctx, bucket string, acc Account, objects []type
|
||||
return err
|
||||
}
|
||||
|
||||
condCtx := requestConditionContext(ctx)
|
||||
condCtx := requestConditionContext(ctx, []Action{BypassGovernanceRetentionAction})
|
||||
for _, obj := range objects {
|
||||
if err := state.checkObject(rctx, be, iam, acc, bucket, obj, bypass, isBucketPublic, condCtx); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1677,3 +1677,327 @@ func AccessControl_bucket_policy_condition_not_ip_address_deny(s *S3Conf) error
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied))
|
||||
})
|
||||
}
|
||||
|
||||
// AccessControl_bucket_policy_condition_if_none_match_required is AWS's
|
||||
// documented "enforce conditional writes" pattern: an unconditional Allow
|
||||
// paired with a Deny that fires whenever the key is absent, forcing every
|
||||
// upload to carry If-None-Match.
|
||||
func AccessControl_bucket_policy_condition_if_none_match_required(s *S3Conf) error {
|
||||
testName := "AccessControl_bucket_policy_condition_if_none_match_required"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
testuser := getUser("user")
|
||||
if err := createUsers(s, []user{testuser}); err != nil {
|
||||
return err
|
||||
}
|
||||
userClient := s.getUserClient(testuser)
|
||||
|
||||
if err := putBucketPolicyDoc(s, bucket,
|
||||
bucketStatement{
|
||||
Effect: "Allow",
|
||||
Principal: testuser.access,
|
||||
Action: "s3:PutObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||
},
|
||||
bucketStatement{
|
||||
Effect: "Deny",
|
||||
Principal: testuser.access,
|
||||
Action: "s3:PutObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||
Condition: json.RawMessage(`{"Null":{"s3:if-none-match":"true"}}`),
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := userClient.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("unconditional"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
})
|
||||
cancel()
|
||||
if err := checkApiErr(err, s3err.GetExplicitDenyAccessErr(testuser.access, "s3:PutObject",
|
||||
fmt.Sprintf("arn:aws:s3:::%s/unconditional", bucket), "a resource-based policy")); err != nil {
|
||||
return fmt.Errorf("an upload without If-None-Match must be denied: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = userClient.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("conditional"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfNoneMatch: getPtr("*"),
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("an upload carrying If-None-Match must be allowed: %w", err)
|
||||
}
|
||||
|
||||
// The same request once the key exists is still authorized; it now
|
||||
// fails on the precondition itself, which the policy has no say in.
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = userClient.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("conditional"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfNoneMatch: getPtr("*"),
|
||||
})
|
||||
cancel()
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrPreconditionFailed))
|
||||
})
|
||||
}
|
||||
|
||||
// AccessControl_bucket_policy_condition_if_none_match_value covers the
|
||||
// key's value rather than its presence: the only value S3 accepts in an
|
||||
// If-None-Match write is "*", and that is what the key carries verbatim -
|
||||
// under StringEquals "*" is a literal, not a wildcard.
|
||||
func AccessControl_bucket_policy_condition_if_none_match_value(s *S3Conf) error {
|
||||
testName := "AccessControl_bucket_policy_condition_if_none_match_value"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
testuser := getUser("user")
|
||||
if err := createUsers(s, []user{testuser}); err != nil {
|
||||
return err
|
||||
}
|
||||
userClient := s.getUserClient(testuser)
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
condition string
|
||||
wantAllow bool
|
||||
}{
|
||||
{"StringEquals on the literal wildcard matches", `{"StringEquals":{"s3:if-none-match":"*"}}`, true},
|
||||
{"StringEquals on any other value does not", `{"StringEquals":{"s3:if-none-match":"abc123"}}`, false},
|
||||
{"Null:false matches a present key", `{"Null":{"s3:if-none-match":"false"}}`, true},
|
||||
{"Null:true does not match a present key", `{"Null":{"s3:if-none-match":"true"}}`, false},
|
||||
{"StringLike wildcard matches", `{"StringLike":{"s3:if-none-match":"*"}}`, true},
|
||||
} {
|
||||
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
|
||||
Effect: "Allow",
|
||||
Principal: testuser.access,
|
||||
Action: "s3:PutObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||
Condition: json.RawMessage(tc.condition),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := userClient.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr(fmt.Sprintf("obj-%v", i)),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfNoneMatch: getPtr("*"),
|
||||
})
|
||||
cancel()
|
||||
|
||||
if tc.wantAllow {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: expected success, got %w", tc.name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AccessControl_bucket_policy_condition_if_match_value covers
|
||||
// s3:if-match's value shape: the key carries the ETag with the surrounding
|
||||
// quotes stripped, so a policy compares against the bare ETag whichever
|
||||
// form the client puts on the wire.
|
||||
func AccessControl_bucket_policy_condition_if_match_value(s *S3Conf) error {
|
||||
testName := "AccessControl_bucket_policy_condition_if_match_value"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
testuser := getUser("user")
|
||||
if err := createUsers(s, []user{testuser}); err != nil {
|
||||
return err
|
||||
}
|
||||
userClient := s.getUserClient(testuser)
|
||||
|
||||
etag, err := putObjectAndGetETag(s3client, bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bare := strings.Trim(etag, `"`)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
condition func() string
|
||||
ifMatch *string
|
||||
wantAllow bool
|
||||
azureUnsupp bool
|
||||
}{
|
||||
{"quoted header matches a bare policy value",
|
||||
func() string { return fmt.Sprintf(`{"StringEquals":{"s3:if-match":%q}}`, bare) }, &etag, true, false},
|
||||
{"unquoted header matches the same bare policy value",
|
||||
func() string { return fmt.Sprintf(`{"StringEquals":{"s3:if-match":%q}}`, bare) }, &bare, true, false},
|
||||
{"a policy value carrying the quotes never matches",
|
||||
func() string { return fmt.Sprintf(`{"StringEquals":{"s3:if-match":%q}}`, etag) }, &etag, false, true},
|
||||
{"a different ETag does not match",
|
||||
func() string { return `{"StringEquals":{"s3:if-match":"0123456789abcdef0123456789abcdef"}}` }, &etag, false, false},
|
||||
{"StringLike wildcard matches",
|
||||
func() string { return `{"StringLike":{"s3:if-match":"*"}}` }, &etag, true, false},
|
||||
{"Null:false matches a present key",
|
||||
func() string { return `{"Null":{"s3:if-match":"false"}}` }, &etag, true, false},
|
||||
} {
|
||||
if tc.azureUnsupp && s.azureTests {
|
||||
continue
|
||||
}
|
||||
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
|
||||
Effect: "Allow",
|
||||
Principal: testuser.access,
|
||||
Action: "s3:PutObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||
Condition: json.RawMessage(tc.condition()),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := userClient.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfMatch: tc.ifMatch,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if tc.wantAllow {
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: expected success, got %w", tc.name, err)
|
||||
}
|
||||
// The overwrite may have changed the ETag, so every
|
||||
// subsequent case has to condition on the current one.
|
||||
if etag, err = headObjectETag(s3client, bucket, "obj"); err != nil {
|
||||
return err
|
||||
}
|
||||
bare = strings.Trim(etag, `"`)
|
||||
continue
|
||||
}
|
||||
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AccessControl_bucket_policy_condition_if_match_delete_object covers the
|
||||
// half of s3:if-match that isn't an upload: S3's conditional delete, which
|
||||
// is the only other action a bucket policy may name the key on.
|
||||
func AccessControl_bucket_policy_condition_if_match_delete_object(s *S3Conf) error {
|
||||
testName := "AccessControl_bucket_policy_condition_if_match_delete_object"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
testuser := getUser("user")
|
||||
if err := createUsers(s, []user{testuser}); err != nil {
|
||||
return err
|
||||
}
|
||||
userClient := s.getUserClient(testuser)
|
||||
|
||||
etag, err := putObjectAndGetETag(s3client, bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A delete that doesn't name the object's ETag is not authorized
|
||||
// at all, so it never reaches the precondition check.
|
||||
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
|
||||
Effect: "Allow",
|
||||
Principal: testuser.access,
|
||||
Action: "s3:DeleteObject",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||
Condition: json.RawMessage(fmt.Sprintf(`{"StringEquals":{"s3:if-match":%q}}`, strings.Trim(etag, `"`))),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = userClient.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
})
|
||||
cancel()
|
||||
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil {
|
||||
return fmt.Errorf("an unconditional delete must be denied: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = userClient.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
IfMatch: &etag,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("a delete naming the object's ETag must be allowed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AccessControl_bucket_policy_condition_if_match_versioned_delete pins the
|
||||
// edge of s3:if-match's action set. A delete naming a version removes that
|
||||
// version rather than overwriting the current one, so it is authorized as
|
||||
// s3:DeleteObjectVersion — an action the key doesn't apply to, which is why
|
||||
// PutBucketPolicy rejects a statement pairing the two. The key has to stay
|
||||
// absent from the request context to match, or a wildcard-action statement
|
||||
// would grant on a precondition the policy language can't name.
|
||||
func AccessControl_bucket_policy_condition_if_match_versioned_delete(s *S3Conf) error {
|
||||
testName := "AccessControl_bucket_policy_condition_if_match_versioned_delete"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
testuser := getUser("user")
|
||||
if err := createUsers(s, []user{testuser}); err != nil {
|
||||
return err
|
||||
}
|
||||
userClient := s.getUserClient(testuser)
|
||||
|
||||
etag, err := putObjectAndGetETag(s3client, bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// s3:* is the only action a bucket policy can name s3:if-match on
|
||||
// and still reach a versioned delete: a wildcard is exempt from
|
||||
// the applicability check an explicit s3:DeleteObjectVersion fails.
|
||||
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
|
||||
Effect: "Allow",
|
||||
Principal: testuser.access,
|
||||
Action: "s3:*",
|
||||
Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||
Condition: json.RawMessage(`{"Null":{"s3:if-match":"false"}}`),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// "null" is the version id every object carries until versioning is
|
||||
// enabled, so this is a versioned delete on any backend.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = userClient.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
VersionId: getPtr("null"),
|
||||
IfMatch: &etag,
|
||||
})
|
||||
cancel()
|
||||
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil {
|
||||
return fmt.Errorf("a versioned delete must leave s3:if-match absent: %w", err)
|
||||
}
|
||||
|
||||
// The same header on the same object, minus the version, is a plain
|
||||
// s3:DeleteObject and does populate the key.
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = userClient.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
IfMatch: &etag,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("an unversioned delete carrying If-Match must be allowed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -807,6 +807,19 @@ func PutBucketPolicy_condition_action_mismatch(s *S3Conf) error {
|
||||
// an explicit multi-action list requires every action to
|
||||
// support the key, even though s3:PutObject alone would.
|
||||
{`["s3:GetObject","s3:PutObject"]`, `{"StringEquals":{"s3:x-amz-acl":"public-read"}}`},
|
||||
// s3:if-match applies to the conditional-write actions only,
|
||||
// so a read action is rejected...
|
||||
{`"s3:GetObject"`, `{"StringEquals":{"s3:if-match":"abc123"}}`},
|
||||
// ... and so is a versioned delete, which names the version to
|
||||
// remove rather than taking an If-Match.
|
||||
{`"s3:DeleteObjectVersion"`, `{"StringEquals":{"s3:if-match":"abc123"}}`},
|
||||
// ... as is a bucket-level write, which reads no If-Match at
|
||||
// all.
|
||||
{`"s3:PutBucketVersioning"`, `{"StringEquals":{"s3:if-match":"abc123"}}`},
|
||||
// s3:if-none-match is narrower still: only s3:PutObject can
|
||||
// require that the object not already exist.
|
||||
{`"s3:DeleteObject"`, `{"Null":{"s3:if-none-match":"false"}}`},
|
||||
{`["s3:PutObject","s3:DeleteObject"]`, `{"Null":{"s3:if-none-match":"false"}}`},
|
||||
} {
|
||||
doc := fmt.Sprintf(`{"Statement":[{"Effect":"Allow","Principal":"*","Action":%s,
|
||||
"Resource":"arn:aws:s3:::%s/*","Condition":%s}]}`, tc.action, bucket, tc.condition)
|
||||
@@ -826,6 +839,43 @@ func PutBucketPolicy_condition_action_mismatch(s *S3Conf) error {
|
||||
})
|
||||
}
|
||||
|
||||
func PutBucketPolicy_condition_conditional_write_keys(s *S3Conf) error {
|
||||
testName := "PutBucketPolicy_condition_conditional_write_keys"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
for _, tc := range []struct {
|
||||
action string
|
||||
condition string
|
||||
}{
|
||||
{`"s3:PutObject"`, `{"StringEquals":{"s3:if-match":"abc123"}}`},
|
||||
{`"s3:PutObject"`, `{"Null":{"s3:if-none-match":"false"}}`},
|
||||
// s3:if-match also covers S3's conditional delete.
|
||||
{`"s3:DeleteObject"`, `{"StringEquals":{"s3:if-match":"abc123"}}`},
|
||||
{`["s3:PutObject","s3:DeleteObject"]`, `{"StringEquals":{"s3:if-match":"abc123"}}`},
|
||||
// a wildcard action is exempt from the applicability check
|
||||
{`"s3:*"`, `{"Null":{"s3:if-none-match":"true"}}`},
|
||||
// key names are case-insensitive
|
||||
{`"s3:PutObject"`, `{"StringEquals":{"S3:IF-MATCH":"abc123"}}`},
|
||||
{`"s3:PutObject"`, `{"Null":{"s3:If-None-Match":"false"}}`},
|
||||
// both keys in one statement
|
||||
{`"s3:PutObject"`, `{"Null":{"s3:if-match":"true","s3:if-none-match":"false"}}`},
|
||||
} {
|
||||
doc := fmt.Sprintf(`{"Statement":[{"Effect":"Allow","Principal":"*","Action":%s,
|
||||
"Resource":"arn:aws:s3:::%s/*","Condition":%s}]}`, tc.action, bucket, tc.condition)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
||||
Bucket: &bucket,
|
||||
Policy: &doc,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("action %s with condition %s: %w", tc.action, tc.condition, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func PutBucketPolicy_condition_invalid_ip(s *S3Conf) error {
|
||||
testName := "PutBucketPolicy_condition_invalid_ip"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
|
||||
@@ -633,6 +633,7 @@ func TestPutBucketPolicy(ts *TestState) {
|
||||
ts.Run(PutBucketPolicy_condition_invalid_operator)
|
||||
ts.Run(PutBucketPolicy_condition_invalid_key)
|
||||
ts.Run(PutBucketPolicy_condition_action_mismatch)
|
||||
ts.Run(PutBucketPolicy_condition_conditional_write_keys)
|
||||
ts.Run(PutBucketPolicy_condition_invalid_ip)
|
||||
}
|
||||
|
||||
@@ -1744,6 +1745,14 @@ func TestS3IAMAccessControl(ts *TestState) {
|
||||
ts.Run(S3IAMAccessControl_condition_principal_tag)
|
||||
ts.Run(S3IAMAccessControl_condition_on_deny_statement)
|
||||
ts.Run(S3IAMAccessControl_condition_multiple_keys_anded)
|
||||
ts.Run(S3IAMAccessControl_condition_if_none_match_required)
|
||||
ts.Run(S3IAMAccessControl_condition_conditional_write_values)
|
||||
ts.Run(S3IAMAccessControl_condition_if_match_delete_object)
|
||||
ts.Run(S3IAMAccessControl_condition_if_match_versioned_delete)
|
||||
ts.Run(S3IAMAccessControl_condition_conditional_write_keys_ignore_reads)
|
||||
ts.Run(S3IAMAccessControl_condition_conditional_write_keys_ignore_copies)
|
||||
ts.Run(S3IAMAccessControl_condition_if_match_bucket_level_write)
|
||||
ts.Run(S3IAMAccessControl_condition_if_match_ignores_delete_objects)
|
||||
ts.Run(S3IAMAccessControl_inactive_and_deleted_credentials)
|
||||
ts.Run(S3IAMAccessControl_access_key_last_used_records_s3)
|
||||
ts.Run(S3IAMPrincipal_accepted_forms)
|
||||
@@ -1875,6 +1884,11 @@ func TestAccessControl(ts *TestState) {
|
||||
ts.Run(AccessControl_bucket_policy_condition_bool_operator)
|
||||
ts.Run(AccessControl_bucket_policy_condition_binary_operator)
|
||||
ts.Run(AccessControl_bucket_policy_condition_null_operator)
|
||||
ts.Run(AccessControl_bucket_policy_condition_if_none_match_required)
|
||||
ts.Run(AccessControl_bucket_policy_condition_if_none_match_value)
|
||||
ts.Run(AccessControl_bucket_policy_condition_if_match_value)
|
||||
ts.Run(AccessControl_bucket_policy_condition_if_match_delete_object)
|
||||
ts.Run(AccessControl_bucket_policy_condition_if_match_versioned_delete)
|
||||
}
|
||||
|
||||
func TestPublicBuckets(ts *TestState) {
|
||||
@@ -2201,6 +2215,14 @@ func GetIntTests() IntTests {
|
||||
"S3IAMAccessControl_condition_principal_tag": S3IAMAccessControl_condition_principal_tag,
|
||||
"S3IAMAccessControl_condition_on_deny_statement": S3IAMAccessControl_condition_on_deny_statement,
|
||||
"S3IAMAccessControl_condition_multiple_keys_anded": S3IAMAccessControl_condition_multiple_keys_anded,
|
||||
"S3IAMAccessControl_condition_if_none_match_required": S3IAMAccessControl_condition_if_none_match_required,
|
||||
"S3IAMAccessControl_condition_conditional_write_values": S3IAMAccessControl_condition_conditional_write_values,
|
||||
"S3IAMAccessControl_condition_if_match_delete_object": S3IAMAccessControl_condition_if_match_delete_object,
|
||||
"S3IAMAccessControl_condition_if_match_versioned_delete": S3IAMAccessControl_condition_if_match_versioned_delete,
|
||||
"S3IAMAccessControl_condition_conditional_write_keys_ignore_reads": S3IAMAccessControl_condition_conditional_write_keys_ignore_reads,
|
||||
"S3IAMAccessControl_condition_conditional_write_keys_ignore_copies": S3IAMAccessControl_condition_conditional_write_keys_ignore_copies,
|
||||
"S3IAMAccessControl_condition_if_match_bucket_level_write": S3IAMAccessControl_condition_if_match_bucket_level_write,
|
||||
"S3IAMAccessControl_condition_if_match_ignores_delete_objects": S3IAMAccessControl_condition_if_match_ignores_delete_objects,
|
||||
"S3IAMAccessControl_inactive_and_deleted_credentials": S3IAMAccessControl_inactive_and_deleted_credentials,
|
||||
"S3IAMSession_bucket_policy_role_arn_covers_every_session": S3IAMSession_bucket_policy_role_arn_covers_every_session,
|
||||
"S3IAMSession_bucket_policy_names_one_session": S3IAMSession_bucket_policy_names_one_session,
|
||||
@@ -3167,6 +3189,7 @@ func GetIntTests() IntTests {
|
||||
"PutBucketPolicy_condition_invalid_operator": PutBucketPolicy_condition_invalid_operator,
|
||||
"PutBucketPolicy_condition_invalid_key": PutBucketPolicy_condition_invalid_key,
|
||||
"PutBucketPolicy_condition_action_mismatch": PutBucketPolicy_condition_action_mismatch,
|
||||
"PutBucketPolicy_condition_conditional_write_keys": PutBucketPolicy_condition_conditional_write_keys,
|
||||
"PutBucketPolicy_condition_invalid_ip": PutBucketPolicy_condition_invalid_ip,
|
||||
"GetBucketPolicy_non_existing_bucket": GetBucketPolicy_non_existing_bucket,
|
||||
"GetBucketPolicy_not_set": GetBucketPolicy_not_set,
|
||||
@@ -3388,6 +3411,11 @@ func GetIntTests() IntTests {
|
||||
"AccessControl_bucket_policy_condition_bool_operator": AccessControl_bucket_policy_condition_bool_operator,
|
||||
"AccessControl_bucket_policy_condition_binary_operator": AccessControl_bucket_policy_condition_binary_operator,
|
||||
"AccessControl_bucket_policy_condition_null_operator": AccessControl_bucket_policy_condition_null_operator,
|
||||
"AccessControl_bucket_policy_condition_if_none_match_required": AccessControl_bucket_policy_condition_if_none_match_required,
|
||||
"AccessControl_bucket_policy_condition_if_none_match_value": AccessControl_bucket_policy_condition_if_none_match_value,
|
||||
"AccessControl_bucket_policy_condition_if_match_value": AccessControl_bucket_policy_condition_if_match_value,
|
||||
"AccessControl_bucket_policy_condition_if_match_delete_object": AccessControl_bucket_policy_condition_if_match_delete_object,
|
||||
"AccessControl_bucket_policy_condition_if_match_versioned_delete": AccessControl_bucket_policy_condition_if_match_versioned_delete,
|
||||
"PublicBucket_default_private_bucket": PublicBucket_default_private_bucket,
|
||||
"PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy,
|
||||
"PublicBucket_public_object_policy": PublicBucket_public_object_policy,
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -2103,6 +2105,515 @@ func S3IAMAccessControl_access_key_last_used_records_s3(s *S3Conf) error {
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_if_none_match_required is AWS's documented
|
||||
// "enforce conditional writes" pattern expressed as an identity policy: an
|
||||
// unconditional Allow paired with a Deny that fires whenever the key is
|
||||
// absent.
|
||||
func S3IAMAccessControl_condition_if_none_match_required(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_if_none_match_required"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(
|
||||
accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)},
|
||||
accessStatement{
|
||||
Effect: "Deny", Action: actS3PutObject, Resource: objectsArn(bucket),
|
||||
Condition: cond("Null", "s3:if-none-match", "true"),
|
||||
},
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = user.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("unconditional"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
})
|
||||
cancel()
|
||||
if err := checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3PutObject, objectArn(bucket, "unconditional"))); err != nil {
|
||||
return fmt.Errorf("an upload without If-None-Match must be denied: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = user.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("conditional"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfNoneMatch: getPtr("*"),
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("an upload carrying If-None-Match must be allowed: %w", err)
|
||||
}
|
||||
|
||||
// Still authorized once the key exists; only the precondition
|
||||
// itself fails now, which the policy has no say in.
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = user.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("conditional"),
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfNoneMatch: getPtr("*"),
|
||||
})
|
||||
cancel()
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrPreconditionFailed))
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_conditional_write_values covers both keys'
|
||||
// values on an upload: s3:if-match carries the ETag with its surrounding
|
||||
// quotes stripped, and s3:if-none-match carries the only value S3 accepts
|
||||
// on a write, the literal "*".
|
||||
func S3IAMAccessControl_condition_conditional_write_values(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_conditional_write_values"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bare := strings.Trim(etag, `"`)
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
condition json.RawMessage
|
||||
// ifMatch and ifNoneMatch are the headers the client sends;
|
||||
// S3 rejects a request carrying both.
|
||||
ifMatch *string
|
||||
ifNoneMatch *string
|
||||
wantAllow bool
|
||||
}{
|
||||
{
|
||||
name: "quoted If-Match header matches a bare policy value",
|
||||
condition: cond("StringEquals", "s3:if-match", bare),
|
||||
ifMatch: &etag,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "unquoted If-Match header matches the same bare policy value",
|
||||
condition: cond("StringEquals", "s3:if-match", bare),
|
||||
ifMatch: &bare,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "a policy value carrying the quotes never matches",
|
||||
condition: cond("StringEquals", "s3:if-match", etag),
|
||||
ifMatch: &etag,
|
||||
},
|
||||
{
|
||||
name: "a different ETag does not match",
|
||||
condition: cond("StringEquals", "s3:if-match", "0123456789abcdef0123456789abcdef"),
|
||||
ifMatch: &etag,
|
||||
},
|
||||
{
|
||||
name: "s3:if-match is absent without the header",
|
||||
condition: cond("Null", "s3:if-match", "true"),
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "an absent s3:if-match cannot satisfy StringEquals",
|
||||
condition: cond("StringEquals", "s3:if-match", bare),
|
||||
},
|
||||
{
|
||||
// Key names are case-insensitive, values are not.
|
||||
name: "key name case is ignored",
|
||||
condition: cond("StringEquals", "S3:IF-MATCH", bare),
|
||||
ifMatch: &etag,
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
name: "s3:if-none-match carries the literal wildcard",
|
||||
condition: cond("StringEquals", "s3:if-none-match", "*"),
|
||||
ifNoneMatch: getPtr("*"),
|
||||
wantAllow: true,
|
||||
},
|
||||
{
|
||||
// "*" is a literal under StringEquals, so it cannot stand
|
||||
// in for an arbitrary value.
|
||||
name: "s3:if-none-match is absent without the header",
|
||||
condition: cond("StringEquals", "s3:if-none-match", "*"),
|
||||
},
|
||||
{
|
||||
name: "one key is absent while the other is present",
|
||||
condition: cond("Null", "s3:if-match", "true"),
|
||||
ifNoneMatch: getPtr("*"),
|
||||
wantAllow: true,
|
||||
},
|
||||
} {
|
||||
// An If-None-Match write has to target a key that doesn't exist
|
||||
// yet, or it is authorized and then fails the precondition -
|
||||
// every other case conditions on "obj"'s own ETag.
|
||||
key := "obj"
|
||||
if tc.ifNoneMatch != nil {
|
||||
key = fmt.Sprintf("absent-%v", i)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket),
|
||||
Condition: tc.condition,
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := user.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
IfMatch: tc.ifMatch,
|
||||
IfNoneMatch: tc.ifNoneMatch,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if !tc.wantAllow {
|
||||
return checkApiErr(err, wantImplicitDeny(user.arn, actS3PutObject, objectArn(bucket, key)))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("expected the request to be allowed: %w", err)
|
||||
}
|
||||
// An overwrite may have changed the ETag, so the remaining
|
||||
// cases have to condition on the current one.
|
||||
if etag, err = headObjectETag(s.GetClient(), bucket, "obj"); err != nil {
|
||||
return err
|
||||
}
|
||||
bare = strings.Trim(etag, `"`)
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_if_match_delete_object covers the half of
|
||||
// s3:if-match that isn't an upload: S3's conditional delete.
|
||||
func S3IAMAccessControl_condition_if_match_delete_object(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_if_match_delete_object"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket),
|
||||
Condition: cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)),
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &bucket, Key: getPtr("obj")})
|
||||
cancel()
|
||||
if err := checkApiErr(err, wantImplicitDeny(user.arn, actS3DeleteObject, objectArn(bucket, "obj"))); err != nil {
|
||||
return fmt.Errorf("an unconditional delete must be denied: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = user.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
IfMatch: &etag,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("a delete naming the object's ETag must be allowed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_if_match_versioned_delete pins the edge of
|
||||
// s3:if-match's action set on the identity-policy side, where nothing
|
||||
// validates a Condition's key against the action it names. A delete naming
|
||||
// a version is authorized as s3:DeleteObjectVersion, which the key doesn't
|
||||
// apply to, so it stays absent and a statement demanding it can never be
|
||||
// satisfied
|
||||
func S3IAMAccessControl_condition_if_match_versioned_delete(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_if_match_versioned_delete"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3DeleteObjectVersion, Resource: objectsArn(bucket),
|
||||
Condition: cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)),
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// "null" is the version id every object carries until versioning is
|
||||
// enabled, so this is a versioned delete on any backend.
|
||||
deleteVersion := func(ifMatch string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
_, err := user.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
VersionId: getPtr("null"),
|
||||
IfMatch: &ifMatch,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkApiErr(deleteVersion(etag),
|
||||
wantImplicitDeny(user.arn, actS3DeleteObjectVersion, objectArn(bucket, "obj"))); err != nil {
|
||||
return fmt.Errorf("a versioned delete must leave s3:if-match absent: %w", err)
|
||||
}
|
||||
|
||||
// Absent, not merely different: the same request satisfies a
|
||||
// statement requiring the key to be absent.
|
||||
if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3DeleteObjectVersion, Resource: objectsArn(bucket),
|
||||
Condition: cond("Null", "s3:if-match", "true"),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Authorized now, and the header still decides the delete: a
|
||||
// mismatch fails the precondition the policy had no say in.
|
||||
if err := checkApiErr(deleteVersion("0123456789abcdef0123456789abcdef"),
|
||||
s3err.GetAPIError(s3err.ErrPreconditionFailed)); err != nil {
|
||||
return fmt.Errorf("the precondition itself must still be enforced: %w", err)
|
||||
}
|
||||
if err := deleteVersion(etag); err != nil {
|
||||
return fmt.Errorf("a versioned delete naming the object's ETag must be allowed: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_if_match_bucket_level_write pins the last
|
||||
// shape a bare PUT can take. A bucket sub-resource write carries none of
|
||||
// the object sub-resources, so the request alone looks exactly like an
|
||||
// upload; only the action it is authorized under separates the two. The
|
||||
// gateway reads no If-Match there, so the key has to stay absent rather
|
||||
// than satisfy a statement demanding a conditional write.
|
||||
func S3IAMAccessControl_condition_if_match_bucket_level_write(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_if_match_bucket_level_write"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bareETag := strings.Trim(etag, `"`)
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3PutBucketOwnershipControls, Resource: bucketArn(bucket),
|
||||
Condition: cond("StringEquals", "s3:if-match", bareETag),
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
putOwnership := func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
_, err := user.client.PutBucketOwnershipControls(ctx, &s3.PutBucketOwnershipControlsInput{
|
||||
Bucket: &bucket,
|
||||
OwnershipControls: &types.OwnershipControls{
|
||||
Rules: []types.OwnershipControlsRule{
|
||||
{ObjectOwnership: types.ObjectOwnershipBucketOwnerPreferred},
|
||||
},
|
||||
},
|
||||
}, withRequestHeader("If-Match", etag))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkApiErr(putOwnership(),
|
||||
wantImplicitDeny(user.arn, actS3PutBucketOwnershipControls, bucketArn(bucket))); err != nil {
|
||||
return fmt.Errorf("a bucket-level write must leave s3:if-match absent: %w", err)
|
||||
}
|
||||
|
||||
// Absent, not merely different: the same request satisfies a
|
||||
// statement requiring the key to be absent.
|
||||
if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3PutBucketOwnershipControls, Resource: bucketArn(bucket),
|
||||
Condition: cond("Null", "s3:if-match", "true"),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := putOwnership(); err != nil {
|
||||
return fmt.Errorf("a bucket-level write must be allowed once the statement stops demanding the key: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_conditional_write_keys_ignore_copies pins
|
||||
// the other half of that rule. A copy takes its preconditions from the
|
||||
// x-amz-copy-source-if-* headers, so the gateway ignores a plain If-Match or
|
||||
// If-None-Match on one — and a policy demanding a conditional write must
|
||||
// therefore keep denying copies rather than be satisfied by a header that
|
||||
// changes nothing.
|
||||
func S3IAMAccessControl_condition_conditional_write_keys_ignore_copies(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_conditional_write_keys_ignore_copies"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
if _, err := putObjectAndGetETag(s.GetClient(), bucket, "src"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(
|
||||
accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)},
|
||||
accessStatement{Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket)},
|
||||
accessStatement{
|
||||
Effect: "Deny", Action: actS3PutObject, Resource: objectsArn(bucket),
|
||||
Condition: cond("Null", "s3:if-none-match", "true"),
|
||||
},
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ifNoneMatch *string
|
||||
}{
|
||||
{"a copy without the header", nil},
|
||||
{"a copy carrying the header", getPtr("*")},
|
||||
} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := user.client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("dst"),
|
||||
CopySource: getPtr(bucket + "/src"),
|
||||
IfNoneMatch: tc.ifNoneMatch,
|
||||
})
|
||||
cancel()
|
||||
if err := checkApiErr(err, wantExplicitIdentityDeny(user.arn, actS3PutObject, objectArn(bucket, "dst"))); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_if_match_ignores_delete_objects covers the
|
||||
// batch delete. DeleteObjects carries one request-level header for the
|
||||
// whole batch and the gateway never applies it to any key, so letting it
|
||||
// populate s3:if-match would authorize deleting every object in the batch
|
||||
// against an ETag nothing checks.
|
||||
func S3IAMAccessControl_condition_if_match_ignores_delete_objects(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_if_match_ignores_delete_objects"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket),
|
||||
Condition: cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)),
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
res, err := user.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: &bucket,
|
||||
Delete: &types.Delete{Objects: objectIdentifiers("obj")},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(res.Errors) != 1 || getString(res.Errors[0].Code) != "AccessDenied" {
|
||||
return fmt.Errorf("expected the batch delete to be denied, instead got %+v", res)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_condition_conditional_write_keys_ignore_reads pins the
|
||||
// keys to writes. GET and HEAD take If-Match/If-None-Match too, as ordinary
|
||||
// HTTP cache preconditions, and neither may populate the condition context
|
||||
// there — otherwise a browser revalidating its cache would decide whether a
|
||||
// read is authorized.
|
||||
func S3IAMAccessControl_condition_conditional_write_keys_ignore_reads(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_condition_conditional_write_keys_ignore_reads"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
etag, err := putObjectAndGetETag(s.GetClient(), bucket, "obj")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, cleanup, err := newS3IAMUser(root, s, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
condition json.RawMessage
|
||||
wantAllow bool
|
||||
}{
|
||||
{"s3:if-match stays absent on a conditional read", cond("Null", "s3:if-match", "true"), true},
|
||||
{"s3:if-none-match stays absent on a conditional read", cond("Null", "s3:if-none-match", "true"), true},
|
||||
{"a read can never satisfy a present-key condition", cond("Null", "s3:if-match", "false"), false},
|
||||
{"nor an equality against the ETag it sent", cond("StringEquals", "s3:if-match", strings.Trim(etag, `"`)), false},
|
||||
} {
|
||||
if err := func() error {
|
||||
if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
|
||||
Condition: tc.condition,
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := user.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: getPtr("obj"),
|
||||
IfMatch: &etag,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if !tc.wantAllow {
|
||||
return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, "obj")))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("expected the request to be allowed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// containsBucket reports whether buckets names bucket, so a listing can be
|
||||
// asserted without depending on what else other tests left behind.
|
||||
func containsBucket(buckets []types.Bucket, bucket string) bool {
|
||||
|
||||
@@ -38,14 +38,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
actS3GetObject = "s3:GetObject"
|
||||
actS3PutObject = "s3:PutObject"
|
||||
actS3DeleteObject = "s3:DeleteObject"
|
||||
actS3DeleteObjectVersion = "s3:DeleteObjectVersion"
|
||||
actS3ListBucket = "s3:ListBucket"
|
||||
actS3CreateBucket = "s3:CreateBucket"
|
||||
actS3ListAllMyBuckets = "s3:ListAllMyBuckets"
|
||||
actS3BypassGovernance = "s3:BypassGovernanceRetention"
|
||||
actS3GetObject = "s3:GetObject"
|
||||
actS3PutObject = "s3:PutObject"
|
||||
actS3DeleteObject = "s3:DeleteObject"
|
||||
actS3DeleteObjectVersion = "s3:DeleteObjectVersion"
|
||||
actS3ListBucket = "s3:ListBucket"
|
||||
actS3CreateBucket = "s3:CreateBucket"
|
||||
actS3ListAllMyBuckets = "s3:ListAllMyBuckets"
|
||||
actS3BypassGovernance = "s3:BypassGovernanceRetention"
|
||||
actS3PutBucketOwnershipControls = "s3:PutBucketOwnershipControls"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1017,6 +1017,61 @@ func putObjects(client *s3.Client, objs []string, bucket string) ([]types.Object
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// putObjectAndGetETag uploads an object and returns its ETag as the wire
|
||||
// carries it, quotes included, for the conditional-write tests that have to
|
||||
// name a real ETag in an If-Match header or a policy condition.
|
||||
func putObjectAndGetETag(client *s3.Client, bucket, key string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &key,
|
||||
Body: bytes.NewReader([]byte("data")),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return getString(res.ETag), nil
|
||||
}
|
||||
|
||||
// withRequestHeader sets a raw header on an SDK request, for the cases no
|
||||
// input field reaches — an If-Match on a bucket-level write, say. The
|
||||
// middleware runs right before signing so the header is signed like any
|
||||
// other.
|
||||
func withRequestHeader(key, value string) func(*s3.Options) {
|
||||
return func(o *s3.Options) {
|
||||
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
|
||||
return stack.Finalize.Insert(
|
||||
middleware.FinalizeMiddlewareFunc("SetRequestHeader",
|
||||
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
|
||||
out middleware.FinalizeOutput, md middleware.Metadata, err error,
|
||||
) {
|
||||
if req, ok := in.Request.(*smithyhttp.Request); ok {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
return next.HandleFinalize(ctx, in)
|
||||
}),
|
||||
"Signing",
|
||||
middleware.Before,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// headObjectETag re-reads an object's current ETag, which an overwrite may
|
||||
// have changed.
|
||||
func headObjectETag(client *s3.Client, bucket, key string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &bucket, Key: &key})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return getString(res.ETag), nil
|
||||
}
|
||||
|
||||
func listObjects(client *s3.Client, bucket, prefix, delimiter string, maxKeys int32) ([]types.Object, []types.CommonPrefix, error) {
|
||||
var contents []types.Object
|
||||
var commonPrefixes []types.CommonPrefix
|
||||
|
||||
Reference in New Issue
Block a user