mirror of
https://github.com/versity/versitygw.git
synced 2026-10-22 14:22:40 +00:00
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.
316 lines
9.4 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|