Files
versitygw/auth/condition_context_test.go
niksis02 08c0904f55 fix: enforce destination If-Match/If-None-Match on CopyObject
CopyObject ignored the destination's `If-Match` and `If-None-Match` headers and always overwrote or created the destination, so a stale `If-Match`, `If-None-Match: *` on an existing key, `If-Match` on a missing key and `If-Match` over a delete marker all returned 200. The controller now parses both headers and passes them to the backend through new `IfMatch` and `IfNoneMatch` fields on `s3response.CopyObjectInput`, which match the SDK's `s3.CopyObjectInput`.

As in S3, the destination is evaluated before the copy source is looked up, so a failing destination precondition wins over a missing source, a failing `x-amz-copy-source-*` precondition and an invalid copy onto itself. Posix checks the destination with `checkPutPreconditions` right after the destination bucket check, then forwards the headers to `PutObject`, which repeats the check under the object publish lock. The unversioned in-place self copy does not go through `PutObject`, so it now takes `lockObjectPublish` and repeats the check itself.

`checkPutPreconditions` now treats a current delete marker as a missing key: `If-Match` returns `NoSuchKey` and `If-None-Match: *` creates a new version. Before, the hidden version's etag was evaluated, which also affected `PutObject` and `CompleteMultipartUpload`.

`EvaluateObjectPutPreconditions` now rejects unsupported headers in S3's order: both headers present, then `If-None-Match` other than `*`, then `If-Match: *`, each with `NotImplemented`. `If-Match: *` was previously compared as a literal etag. The new `NmpAdditionalMessageIfMatch` message is added, and a stray backslash is removed from the `If-None-Match` message.

Azure evaluates the destination preconditions before copying, and s3proxy forwards the headers. `s3:if-match` and `s3:if-none-match` are now populated in the policy condition context for CopyObject, as S3 does, while UploadPartCopy stays excluded.
2026-10-06 00:27:50 +02:00

316 lines
9.4 KiB
Go

// 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's If-Match/If-None-Match are conditional writes on
// its destination, like a plain upload's.
name: "a copy populates both keys",
method: fiber.MethodPut,
ifMatch: `"abc123"`,
ifNoneMatch: "*",
copySource: "/src-bucket/src-key",
actions: []Action{PutObjectAction},
want: map[string][]string{
"s3:if-match": {"abc123"},
"s3:if-none-match": {"*"},
},
},
{
name: "an upload part copy populates neither key",
method: fiber.MethodPut,
query: "partNumber=1&uploadId=abc",
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)
}
})
}
}