fix(auth): secure proxied tagging and streaming trailers

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-11-11 11:13:26 +08:00
parent e51bff35a3
commit 14ecb222e7
5 changed files with 213 additions and 31 deletions
+9 -1
View File
@@ -689,7 +689,15 @@ func setAuthMiddleware(h http.Handler) http.Handler {
aType := getRequestAuthType(r) aType := getRequestAuthType(r)
switch aType { switch aType {
case authTypeSigned, authTypeSignedV2, authTypeStreamingSigned, authTypeStreamingSignedTrailer: case authTypeSigned, authTypeSignedV2, authTypeStreamingSigned, authTypeStreamingSignedTrailer, authTypeStreamingUnsignedTrailer:
// STREAMING-UNSIGNED-PAYLOAD-TRAILER is valid for a genuinely
// anonymous request. Once Authorization is present, it is a
// signed REST-HEADER request and must obey the same date/skew
// bound as the other SigV4 streaming forms.
if aType == authTypeStreamingUnsignedTrailer && r.Header.Get(xhttp.Authorization) == "" {
h.ServeHTTP(w, r)
return
}
// Verify if date headers are set, if not reject the request // Verify if date headers are set, if not reject the request
amzDate, errCode := parseAmzDateHeader(r) amzDate, errCode := parseAmzDateHeader(r)
if errCode != ErrNone { if errCode != ErrNone {
+13 -2
View File
@@ -213,16 +213,27 @@ func getConditionValuesWithTags(r *http.Request, lc string, cred auth.Credential
switch authType { switch authType {
case authTypeSignedV2, authTypePresignedV2: case authTypeSignedV2, authTypePresignedV2:
signatureVersion = signV2Algorithm signatureVersion = signV2Algorithm
case authTypeSigned, authTypePresigned, authTypeStreamingSigned, authTypePostPolicy: case authTypeSigned, authTypePresigned, authTypeStreamingSigned,
authTypeStreamingSignedTrailer, authTypePostPolicy:
signatureVersion = signV4Algorithm signatureVersion = signV4Algorithm
case authTypeStreamingUnsignedTrailer:
// This form is also valid for a genuinely anonymous request. Only
// expose SigV4 condition values when header authentication is present.
if r.Header.Get(xhttp.Authorization) != "" {
signatureVersion = signV4Algorithm
}
} }
var authtype string var authtype string
switch authType { switch authType {
case authTypePresignedV2, authTypePresigned: case authTypePresignedV2, authTypePresigned:
authtype = "REST-QUERY-STRING" authtype = "REST-QUERY-STRING"
case authTypeSignedV2, authTypeSigned, authTypeStreamingSigned: case authTypeSignedV2, authTypeSigned, authTypeStreamingSigned, authTypeStreamingSignedTrailer:
authtype = "REST-HEADER" authtype = "REST-HEADER"
case authTypeStreamingUnsignedTrailer:
if r.Header.Get(xhttp.Authorization) != "" {
authtype = "REST-HEADER"
}
case authTypePostPolicy: case authTypePostPolicy:
authtype = "POST" authtype = "POST"
} }
+22 -8
View File
@@ -2852,6 +2852,7 @@ func proxyGetTaggingToRepTarget(ctx context.Context, bucket, object string, opts
var wg sync.WaitGroup var wg sync.WaitGroup
errs := make([]error, len(proxyTargets.Targets)) errs := make([]error, len(proxyTargets.Targets))
tagSlc := make([]map[string]string, len(proxyTargets.Targets)) tagSlc := make([]map[string]string, len(proxyTargets.Targets))
contacted := 0
for idx, t := range proxyTargets.Targets { for idx, t := range proxyTargets.Targets {
tgt := globalBucketTargetSys.GetRemoteTargetClient(bucket, t.Arn) tgt := globalBucketTargetSys.GetRemoteTargetClient(bucket, t.Arn)
if tgt == nil || globalBucketTargetSys.isOffline(tgt.EndpointURL()) { if tgt == nil || globalBucketTargetSys.isOffline(tgt.EndpointURL()) {
@@ -2861,37 +2862,50 @@ func proxyGetTaggingToRepTarget(ctx context.Context, bucket, object string, opts
if tgt.disableProxy { if tgt.disableProxy {
continue continue
} }
contacted++
idx := idx idx := idx
wg.Add(1) wg.Add(1)
go func(idx int, tgt *TargetClient) { go func(idx int, tgt *TargetClient) {
defer wg.Done() defer wg.Done()
var err error
gopts := minio.GetObjectTaggingOptions{ gopts := minio.GetObjectTaggingOptions{
VersionID: opts.VersionID, VersionID: opts.VersionID,
Internal: minio.AdvancedObjectTaggingOptions{ Internal: minio.AdvancedObjectTaggingOptions{
ReplicationProxyRequest: "true", ReplicationProxyRequest: "true",
}, },
} }
tgs, err = tgt.GetObjectTagging(ctx, tgt.Bucket, object, gopts) remoteTags, err := tgt.GetObjectTagging(ctx, tgt.Bucket, object, gopts)
if err != nil { if err != nil {
errs[idx] = err errs[idx] = err
} else { } else if remoteTags != nil {
tagSlc[idx] = tgs.ToMap() tagSlc[idx] = remoteTags.ToMap()
} }
}(idx, tgt) }(idx, tgt)
} }
wg.Wait() wg.Wait()
if contacted == 0 {
return nil, proxyResult{Err: RemoteTargetConnectionErr{
Bucket: bucket,
Err: errors.New("no available remote replication target"),
}}
}
var firstNotFound error
for idx, err := range errs { for idx, err := range errs {
errCode := minio.ToErrorResponse(err).Code errCode := minio.ToErrorResponse(err).Code
if err != nil && errCode != "NoSuchKey" && errCode != "NoSuchVersion" { if err != nil {
if errCode != "NoSuchKey" && errCode != "NoSuchVersion" {
return nil, proxyResult{Err: err} return nil, proxyResult{Err: err}
} }
if err == nil { if firstNotFound == nil {
firstNotFound = err
}
continue
}
if tagSlc[idx] != nil && tgs == nil {
tgs, _ = tags.MapToObjectTags(tagSlc[idx]) tgs, _ = tags.MapToObjectTags(tagSlc[idx])
} }
} }
if len(errs) == 1 { if tgs == nil {
proxy.Err = errs[0] proxy.Err = firstNotFound
} }
return tgs, proxy return tgs, proxy
} }
+81 -18
View File
@@ -3754,6 +3754,18 @@ func (api objectAPIHandlers) GetObjectTaggingHandler(w http.ResponseWriter, r *h
writeSuccessResponseXML(w, encodeResponse(otags)) writeSuccessResponseXML(w, encodeResponse(otags))
} }
func isRemoteTaggingNotFound(err error) bool {
if err == nil {
return false
}
switch miniogo.ToErrorResponse(err).Code {
case "NoSuchKey", "NoSuchVersion":
return true
default:
return false
}
}
// PutObjectTaggingHandler - PUT object tagging // PutObjectTaggingHandler - PUT object tagging
func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *http.Request) { func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *http.Request) {
ctx := newContext(r, w, "PutObjectTagging") ctx := newContext(r, w, "PutObjectTagging")
@@ -3773,6 +3785,13 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
return return
} }
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
if s3Error := authenticateRequest(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
// Tags XML will not be bigger than 1MiB in size, fail if its bigger. // Tags XML will not be bigger than 1MiB in size, fail if its bigger.
tags, err := tags.ParseObjectXML(io.LimitReader(r.Body, 1<<20)) tags, err := tags.ParseObjectXML(io.LimitReader(r.Body, 1<<20))
if err != nil { if err != nil {
@@ -3781,13 +3800,6 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
} }
tagsStr := tags.String() tagsStr := tags.String()
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
if s3Error := authenticateRequest(ctx, r, policy.PutObjectTaggingAction); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
// Set this such that authorization policies can be applied on the object // Set this such that authorization policies can be applied on the object
// tags. This is derived from the request body, so it must be injected only // tags. This is derived from the request body, so it must be injected only
// after signature verification: the SigV4 verifier now rejects unsigned // after signature verification: the SigV4 verifier now rejects unsigned
@@ -3801,16 +3813,6 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
} }
objInfo, err := objAPI.GetObjectInfo(ctx, bucket, object, opts) objInfo, err := objAPI.GetObjectInfo(ctx, bucket, object, opts)
var s3Error APIErrorCode
if err != nil {
s3Error = authorizeRequestWithUnreadTags(ctx, r, policy.PutObjectTaggingAction, err)
} else {
s3Error = authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, objInfo.UserTags)
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
if err != nil { if err != nil {
// if object is not found locally, but exists on peer site - proxy // if object is not found locally, but exists on peer site - proxy
// the tagging request to peer site. The response to client will // the tagging request to peer site. The response to client will
@@ -3818,6 +3820,23 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
if isErrObjectNotFound(err) || isErrVersionNotFound(err) { if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
proxytgts := getProxyTargets(ctx, bucket, object, opts) proxytgts := getProxyTargets(ctx, bucket, object, opts)
if !proxytgts.Empty() { if !proxytgts.Empty() {
// The local miss does not authorize a remote mutation. Read
// the addressed remote version first, then evaluate the
// caller against its actual ExistingObjectTag values.
remoteTags, readProxy := proxyGetTaggingToRepTarget(ctx, bucket, object, opts, proxytgts)
existingTags := ""
if remoteTags != nil {
existingTags = remoteTags.String()
}
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, existingTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
if readProxy.Err != nil && !isRemoteTaggingNotFound(readProxy.Err) {
globalReplicationStats.Load().incProxy(bucket, putObjectTaggingAPI, true)
writeErrorResponse(ctx, w, toAPIError(ctx, readProxy.Err), r.URL)
return
}
globalReplicationStats.Load().incProxy(bucket, putObjectTaggingAPI, false) globalReplicationStats.Load().incProxy(bucket, putObjectTaggingAPI, false)
// proxy to replication target if site replication is in place. // proxy to replication target if site replication is in place.
perr := proxyTaggingToRepTarget(ctx, bucket, object, tags, opts, proxytgts) perr := proxyTaggingToRepTarget(ctx, bucket, object, tags, opts, proxytgts)
@@ -3841,9 +3860,26 @@ func (api objectAPIHandlers) PutObjectTaggingHandler(w http.ResponseWriter, r *h
return return
} }
} }
// A local read failure must never be turned into a write grant. A
// server error may still be disclosed to an otherwise authorized
// caller for retry, while a missing object is evaluated as untagged.
var s3Error APIErrorCode
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
s3Error = authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, "")
} else {
s3Error = authorizeRequestWithUnreadTags(ctx, r, policy.PutObjectTaggingAction, err)
}
if s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return return
} }
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.PutObjectTaggingAction, objInfo.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
dsc := mustReplicate(ctx, bucket, object, getMustReplicateOptions(objInfo.UserDefined, tagsStr, objInfo.ReplicationStatus, replication.MetadataReplicationType, opts)) dsc := mustReplicate(ctx, bucket, object, getMustReplicateOptions(objInfo.UserDefined, tagsStr, objInfo.ReplicationStatus, replication.MetadataReplicationType, opts))
stamp := UTCNow().Format(time.RFC3339Nano) stamp := UTCNow().Format(time.RFC3339Nano)
@@ -3900,6 +3936,13 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
return return
} }
logger.GetReqInfo(ctx).BucketName = bucket
logger.GetReqInfo(ctx).ObjectName = object
if s3Error := authenticateRequest(ctx, r, policy.DeleteObjectTaggingAction); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
opts, err := getOpts(ctx, r, bucket, object) opts, err := getOpts(ctx, r, bucket, object)
if err != nil { if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
@@ -3914,6 +3957,22 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
if isErrObjectNotFound(err) || isErrVersionNotFound(err) { if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
proxytgts := getProxyTargets(ctx, bucket, object, opts) proxytgts := getProxyTargets(ctx, bucket, object, opts)
if !proxytgts.Empty() { if !proxytgts.Empty() {
// Authenticate above and authorize against the remote version's
// tags before sending a destructive proxy request.
remoteTags, readProxy := proxyGetTaggingToRepTarget(ctx, bucket, object, opts, proxytgts)
existingTags := ""
if remoteTags != nil {
existingTags = remoteTags.String()
}
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, existingTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
if readProxy.Err != nil && !isRemoteTaggingNotFound(readProxy.Err) {
globalReplicationStats.Load().incProxy(bucket, removeObjectTaggingAPI, true)
writeErrorResponse(ctx, w, toAPIError(ctx, readProxy.Err), r.URL)
return
}
globalReplicationStats.Load().incProxy(bucket, removeObjectTaggingAPI, false) globalReplicationStats.Load().incProxy(bucket, removeObjectTaggingAPI, false)
// proxy to replication target if active-active replication is in place. // proxy to replication target if active-active replication is in place.
perr := proxyTaggingToRepTarget(ctx, bucket, object, nil, opts, proxytgts) perr := proxyTaggingToRepTarget(ctx, bucket, object, nil, opts, proxytgts)
@@ -3938,12 +3997,16 @@ func (api objectAPIHandlers) DeleteObjectTaggingHandler(w http.ResponseWriter, r
return return
} }
} }
if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, ""); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return
}
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return return
} }
// Allow deleteObjectTagging if policy action is set // Allow deleteObjectTagging if policy action is set
if s3Error := checkRequestAuthTypeWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, bucket, object, oi.UserTags); s3Error != ErrNone { if s3Error := authorizeRequestWithExistingTags(ctx, r, policy.DeleteObjectTaggingAction, oi.UserTags); s3Error != ErrNone {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL)
return return
} }
+87 -1
View File
@@ -24,6 +24,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/auth"
xhttp "github.com/minio/minio/internal/http" xhttp "github.com/minio/minio/internal/http"
@@ -35,7 +36,7 @@ func TestAPIStreamingAuthenticationBoundaries(t *testing.T) {
t: t, endpoints: []string{"PutObject"}, t: t, endpoints: []string{"PutObject"},
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) { objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
data := []byte("authenticated streaming payload") data := []byte("authenticated streaming payload")
for _, mode := range []string{"valid", "missing-algorithm", "wrong-algorithm", "wrong-signature"} { for _, mode := range []string{"valid", "missing-algorithm", "wrong-algorithm", "wrong-signature", "invalid-credential"} {
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, getPutObjectURL("", bucket, mode), data, UTCNow()) req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, getPutObjectURL("", bucket, mode), data, UTCNow())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -55,6 +56,8 @@ func TestAPIStreamingAuthenticationBoundaries(t *testing.T) {
t.Fatal("signed request has no signature") t.Fatal("signed request has no signature")
} }
authorization = prefix + "Signature=" + strings.Repeat("0", 64) authorization = prefix + "Signature=" + strings.Repeat("0", 64)
case "invalid-credential":
authorization = strings.Replace(authorization, cred.AccessKey, strings.Repeat("A", len(cred.AccessKey)), 1)
} }
req.Header.Set(xhttp.Authorization, authorization) req.Header.Set(xhttp.Authorization, authorization)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -112,3 +115,86 @@ func TestAPIStreamingAuthenticationBoundaries(t *testing.T) {
}, },
}) })
} }
func TestStreamingTrailerConditionValues(t *testing.T) {
tests := []struct {
name, payload, authorization string
wantVersion, wantAuthType string
}{
{
name: "signed trailer",
payload: streamingContentSHA256Trailer,
authorization: "AWS4-HMAC-SHA256 Credential=test",
wantVersion: signV4Algorithm,
wantAuthType: "REST-HEADER",
},
{
name: "authenticated unsigned trailer",
payload: unsignedPayloadTrailer,
authorization: "AWS4-HMAC-SHA256 Credential=test",
wantVersion: signV4Algorithm,
wantAuthType: "REST-HEADER",
},
{
name: "anonymous unsigned trailer",
payload: unsignedPayloadTrailer,
wantVersion: "",
wantAuthType: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "http://minio.local/bucket/object", nil)
r.Header.Set(xhttp.AmzContentSha256, tc.payload)
if tc.authorization != "" {
r.Header.Set(xhttp.Authorization, tc.authorization)
}
values := getConditionValues(r, "", auth.Credentials{})
if got := strings.Join(values["signatureversion"], ""); got != tc.wantVersion {
t.Fatalf("signatureversion = %q, want %q", got, tc.wantVersion)
}
if got := strings.Join(values["authType"], ""); got != tc.wantAuthType {
t.Fatalf("authType = %q, want %q", got, tc.wantAuthType)
}
})
}
}
func TestStreamingUnsignedTrailerDateGate(t *testing.T) {
tests := []struct {
name string
date string
authorized bool
wantCall bool
}{
{name: "anonymous without date", authorized: false, wantCall: true},
{name: "authenticated current", date: UTCNow().Format(iso8601Format), authorized: true, wantCall: true},
{name: "authenticated missing", authorized: true, wantCall: false},
{name: "authenticated malformed", date: "not-a-date", authorized: true, wantCall: false},
{name: "authenticated old", date: UTCNow().Add(-globalMaxSkewTime - time.Second).Format(iso8601Format), authorized: true, wantCall: false},
{name: "authenticated future", date: UTCNow().Add(globalMaxSkewTime + time.Second).Format(iso8601Format), authorized: true, wantCall: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "http://minio.local/bucket/object", nil)
r.Header.Set(xhttp.AmzContentSha256, unsignedPayloadTrailer)
if tc.authorized {
r.Header.Set(xhttp.Authorization, "AWS4-HMAC-SHA256 Credential=test")
}
if tc.date != "" {
r.Header.Set(xhttp.AmzDate, tc.date)
}
called := false
h := setAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if called != tc.wantCall {
t.Fatalf("handler called = %v, want %v; status=%d body=%q", called, tc.wantCall, rec.Code, rec.Body.String())
}
})
}
}