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.
179 lines
7.2 KiB
Go
179 lines
7.2 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 (
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
)
|
|
|
|
// requestConditionContext builds the IAM policy-condition keys describing
|
|
// this request — aws:SourceIp, aws:SecureTransport, aws:CurrentTime and
|
|
// friends — for identity-policy and bucket-policy Condition blocks to
|
|
// evaluate against. The identity-derived keys (aws:PrincipalArn,
|
|
// 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.
|
|
//
|
|
// 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)},
|
|
"aws:EpochTime": {strconv.FormatInt(now.Unix(), 10)},
|
|
"aws:SecureTransport": {strconv.FormatBool(ctx.Secure())},
|
|
}
|
|
// ctx.IP() is the real peer address: the gateway's fiber app configures
|
|
// neither ProxyHeader nor TrustProxy, so no client-supplied header can
|
|
// influence it. Adding either for logging would make aws:SourceIp
|
|
// client-controlled — revisit this if that ever changes.
|
|
if ip := ctx.IP(); ip != "" {
|
|
condCtx["aws:SourceIp"] = []string{ip}
|
|
}
|
|
if ua := ctx.Get("User-Agent"); ua != "" {
|
|
condCtx["aws:UserAgent"] = []string{ua}
|
|
}
|
|
if ref := ctx.Get("Referer"); ref != "" {
|
|
condCtx["aws:Referer"] = []string{ref}
|
|
}
|
|
if prefix := ctx.Query("prefix"); prefix != "" {
|
|
condCtx["s3:prefix"] = []string{prefix}
|
|
}
|
|
if delim := ctx.Query("delimiter"); delim != "" {
|
|
condCtx["s3:delimiter"] = []string{delim}
|
|
}
|
|
if maxKeys := ctx.Query("max-keys"); maxKeys != "" {
|
|
condCtx["s3:max-keys"] = []string{maxKeys}
|
|
}
|
|
if acl := ctx.Get("X-Amz-Acl"); acl != "" {
|
|
condCtx["s3:x-amz-acl"] = []string{acl}
|
|
}
|
|
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,
|
|
// CopyObject and CompleteMultipartUpload, all 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 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 and query would route to, before checking
|
|
// what the request is actually authorized as.
|
|
func conditionalWriteRouteAction(ctx fiber.Ctx) Action {
|
|
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
|
|
}
|