diff --git a/cmd/api-headers.go b/cmd/api-headers.go index 97a35ed07..a9f2bb826 100644 --- a/cmd/api-headers.go +++ b/cmd/api-headers.go @@ -159,6 +159,11 @@ func setObjectHeaders(ctx context.Context, w http.ResponseWriter, objInfo Object continue } + // Tag count and tag values are gated above by opts.TaggingCount / opts.Tagging. + if equals(k, xhttp.AmzTagCount, xhttp.AmzObjectTagging) { + continue + } + // https://github.com/google/security-research/security/advisories/GHSA-76wf-9vgp-pj7w if equals(k, xhttp.AmzMetaUnencryptedContentLength, xhttp.AmzMetaUnencryptedContentMD5) { continue diff --git a/cmd/bucket-replication.go b/cmd/bucket-replication.go index a4d128a6c..18dce44a2 100644 --- a/cmd/bucket-replication.go +++ b/cmd/bucket-replication.go @@ -2711,6 +2711,9 @@ func proxyHeadToRepTarget(ctx context.Context, bucket, object string, rs *HTTPRa } oi.UserDefined = make(map[string]string, len(objInfo.Metadata)) for k, v := range objInfo.Metadata { + if equals(k, xhttp.AmzTagCount, xhttp.AmzObjectTagging) { + continue + } oi.UserDefined[k] = v[0] } ce, ok := oi.UserDefined[xhttp.ContentEncoding] diff --git a/cmd/tagging-proxy-authz_test.go b/cmd/tagging-proxy-authz_test.go index e50fc8343..3eebc2410 100644 --- a/cmd/tagging-proxy-authz_test.go +++ b/cmd/tagging-proxy-authz_test.go @@ -178,3 +178,149 @@ func TestAPITaggingProxyAuthorization(t *testing.T) { } }}) } + +func headerValuesFold(h http.Header, name string) []string { + var out []string + for k, vals := range h { + if strings.EqualFold(k, name) { + out = append(out, vals...) + } + } + return out +} + +func TestAPIProxyGetHeadTagCountAndLockHeaders(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, root auth.Credentials, t *testing.T) { + explicitDenyTagAndLock := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[ + {"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"]}, + {"Effect":"Deny","Action":["s3:GetObjectTagging","s3:GetObjectVersionTagging","s3:GetObjectRetention","s3:GetObjectLegalHold"],"Resource":["arn:aws:s3:::%s/*"]} + ]`, bucket, bucket)) + readOnlyNoTagNoLock := newDeleteAuthzPolicyUser(t, backend, bucket, fmt.Sprintf(`[ + {"Effect":"Allow","Action":["s3:GetObject","s3:GetObjectVersion"],"Resource":["arn:aws:s3:::%s/*"]} + ]`, bucket)) + + globalBucketTargetSys.Lock() + globalBucketTargetSys.targetsMap[bucket] = nil + globalBucketTargetSys.Unlock() + + const payload = "remote-active-active-body" + const etag = "d41d8cd98f00b204e9800998ecf8427e" + + target := newILMTestTarget(t) + registerILMTestTarget(t, obj, bucket, target, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"`+etag+`"`) + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(payload))) + w.Header().Set("Last-Modified", "Wed, 12 Nov 2026 00:00:00 GMT") + w.Header()[xhttp.AmzTagCount] = []string{"2"} + if strings.EqualFold(r.Header.Get(xhttp.AmzTagDirective), accessDirective) { + w.Header()[xhttp.AmzObjectTagging] = []string{"secret=alpha&tier=gold"} + } + w.Header().Set(xhttp.AmzObjectLockMode, "GOVERNANCE") + w.Header().Set(xhttp.AmzObjectLockRetainUntilDate, "2026-12-31T00:00:00Z") + w.Header().Set(xhttp.AmzObjectLockLegalHold, "ON") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(payload)) + } + }) + + globalBucketTargetSys.Lock() + globalBucketTargetSys.targetsMap[bucket][0].Credentials = &madmin.Credentials{ + AccessKey: "repl-svc", + SecretKey: "repl-svc-secret", + } + globalBucketTargetSys.Unlock() + + rule := configs[0].Rules[0] + rule.Priority = 1 + rule.Destination = replication.Destination{ARN: target.arn, Bucket: target.bucket} + cfg := replication.Config{Rules: []replication.Rule{rule}} + + meta, err := globalBucketMetadataSys.Get(bucket) + if err != nil { + t.Fatal(err) + } + meta.replicationConfig = &cfg + globalBucketMetadataSys.Set(bucket, meta) + + for _, method := range []string{http.MethodHead, http.MethodGet} { + for _, tc := range []struct { + name string + cred auth.Credentials + }{ + {name: "explicit-deny-tag-and-lock", cred: explicitDenyTagAndLock}, + {name: "implicit-no-tag-or-lock", cred: readOnlyNoTagNoLock}, + } { + t.Run(backend+"/"+method+"/"+tc.name, func(t *testing.T) { + req, err := newTestSignedRequestV4(method, "/"+bucket+"/remote-only.txt", 0, nil, tc.cred.AccessKey, tc.cred.SecretKey, map[string]string{xhttp.AmzTagDirective: accessDirective}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200: %s", rec.Code, rec.Body.String()) + } + wire := rec.Result().Header + for _, hdr := range []string{ + "X-Amz-Tagging-Count", + "X-Amz-Tagging", + "X-Amz-Object-Lock-Mode", + "X-Amz-Object-Lock-Retain-Until-Date", + "X-Amz-Object-Lock-Legal-Hold", + } { + if got := headerValuesFold(wire, hdr); len(got) != 0 { + t.Errorf("unauthorized caller leaked %s=%v (raw=%v)", hdr, got, rec.Header()) + } + } + }) + } + + t.Run(backend+"/"+method+"/authorized-single-tag-count-and-lock-preserved", func(t *testing.T) { + req, err := newTestSignedRequestV4(method, "/"+bucket+"/remote-only.txt", 0, nil, root.AccessKey, root.SecretKey, nil) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200: %s", rec.Code, rec.Body.String()) + } + wire := rec.Result().Header + if got := headerValuesFold(wire, "X-Amz-Tagging-Count"); len(got) != 1 || got[0] != "2" { + t.Errorf("authorized X-Amz-Tagging-Count=%v, want single [2] (raw=%v)", got, rec.Header()) + } + if got := headerValuesFold(wire, "X-Amz-Tagging"); len(got) != 0 { + t.Errorf("unexpected X-Amz-Tagging without ACCESS directive: %v", got) + } + if got := wire.Get("X-Amz-Object-Lock-Mode"); got != "GOVERNANCE" { + t.Errorf("authorized X-Amz-Object-Lock-Mode=%q, want GOVERNANCE", got) + } + if got := wire.Get("X-Amz-Object-Lock-Retain-Until-Date"); got != "2026-12-31T00:00:00Z" { + t.Errorf("authorized X-Amz-Object-Lock-Retain-Until-Date=%q, want 2026-12-31T00:00:00Z", got) + } + if got := wire.Get("X-Amz-Object-Lock-Legal-Hold"); got != "ON" { + t.Errorf("authorized X-Amz-Object-Lock-Legal-Hold=%q, want ON", got) + } + + reqAccess, err := newTestSignedRequestV4(method, "/"+bucket+"/remote-only.txt", 0, nil, root.AccessKey, root.SecretKey, map[string]string{xhttp.AmzTagDirective: accessDirective}) + if err != nil { + t.Fatal(err) + } + recAccess := httptest.NewRecorder() + router.ServeHTTP(recAccess, reqAccess) + if recAccess.Code != http.StatusOK { + t.Fatalf("ACCESS status=%d, want 200: %s", recAccess.Code, recAccess.Body.String()) + } + wireAccess := recAccess.Result().Header + if got := headerValuesFold(wireAccess, "X-Amz-Tagging-Count"); len(got) != 1 || got[0] != "2" { + t.Errorf("ACCESS X-Amz-Tagging-Count=%v, want single [2]", got) + } + if got := headerValuesFold(wireAccess, "X-Amz-Tagging"); len(got) != 1 || got[0] != "secret=alpha&tier=gold" { + t.Errorf("ACCESS X-Amz-Tagging=%v, want [secret=alpha&tier=gold]", got) + } + }) + } + }}) +} diff --git a/internal/bucket/object/lock/lock.go b/internal/bucket/object/lock/lock.go index b7bfdb9ef..e5c437e8c 100644 --- a/internal/bucket/object/lock/lock.go +++ b/internal/bucket/object/lock/lock.go @@ -466,29 +466,34 @@ func ParseObjectLockRetentionHeaders(h http.Header, allowPastRetainDate bool) (r return rmode, RetentionDate{retDate}, nil } +func lookupMetaFold(meta map[string]string, key string) (string, bool) { + if v, ok := meta[strings.ToLower(key)]; ok { + return v, true + } + if v, ok := meta[key]; ok { + return v, true + } + for k, v := range meta { + if strings.EqualFold(k, key) { + return v, true + } + } + return "", false +} + // GetObjectRetentionMeta constructs ObjectRetention from metadata func GetObjectRetentionMeta(meta map[string]string) ObjectRetention { var mode RetMode var retainTill RetentionDate - var modeStr, tillStr string - ok := false - - modeStr, ok = meta[strings.ToLower(AmzObjectLockMode)] - if !ok { - modeStr, ok = meta[AmzObjectLockMode] - } + modeStr, ok := lookupMetaFold(meta, AmzObjectLockMode) if ok { mode = parseRetMode(modeStr) } else { return ObjectRetention{} } - tillStr, ok = meta[strings.ToLower(AmzObjectLockRetainUntilDate)] - if !ok { - tillStr, ok = meta[AmzObjectLockRetainUntilDate] - } - if ok { + if tillStr, ok := lookupMetaFold(meta, AmzObjectLockRetainUntilDate); ok { if t, e := amztime.ISO8601Parse(tillStr); e == nil { retainTill = RetentionDate{t.UTC()} } @@ -498,11 +503,7 @@ func GetObjectRetentionMeta(meta map[string]string) ObjectRetention { // GetObjectLegalHoldMeta constructs ObjectLegalHold from metadata func GetObjectLegalHoldMeta(meta map[string]string) ObjectLegalHold { - holdStr, ok := meta[strings.ToLower(AmzObjectLockLegalHold)] - if !ok { - holdStr, ok = meta[AmzObjectLockLegalHold] - } - if ok { + if holdStr, ok := lookupMetaFold(meta, AmzObjectLockLegalHold); ok { return ObjectLegalHold{XMLNS: "http://s3.amazonaws.com/doc/2006-03-01/", Status: parseLegalHoldStatus(holdStr)} } return ObjectLegalHold{} @@ -592,16 +593,17 @@ func FilterObjectLockMetadata(metadata map[string]string, filterRetention, filte dst := metadata var copied bool delKey := func(key string) { - key = strings.ToLower(key) - if _, ok := metadata[key]; !ok { - return + for k := range metadata { + if !strings.EqualFold(k, key) { + continue + } + if !copied { + dst = make(map[string]string, len(metadata)) + maps.Copy(dst, metadata) + copied = true + } + delete(dst, k) } - if !copied { - dst = make(map[string]string, len(metadata)) - maps.Copy(dst, metadata) - copied = true - } - delete(dst, key) } legalHold := GetObjectLegalHoldMeta(metadata) if !legalHold.Status.Valid() || filterLegalHold { diff --git a/internal/bucket/object/lock/lock_test.go b/internal/bucket/object/lock/lock_test.go index c53d77b86..018a998c1 100644 --- a/internal/bucket/object/lock/lock_test.go +++ b/internal/bucket/object/lock/lock_test.go @@ -608,6 +608,28 @@ func TestFilterObjectLockMetadata(t *testing.T) { "x-amz-object-lock-retain-until-date": "2020-02-01", }, }, + { + metadata: map[string]string{ + "X-Amz-Object-Lock-Legal-Hold": "ON", + "X-Amz-Object-Lock-Mode": "GOVERNANCE", + "X-Amz-Object-Lock-Retain-Until-Date": "2026-12-31T00:00:00Z", + }, + expected: map[string]string{}, + filterRetention: true, + filterLegalHold: true, + }, + { + metadata: map[string]string{ + "X-Amz-Object-Lock-Legal-Hold": "ON", + "X-Amz-Object-Lock-Mode": "GOVERNANCE", + "X-Amz-Object-Lock-Retain-Until-Date": "2026-12-31T00:00:00Z", + }, + expected: map[string]string{ + "X-Amz-Object-Lock-Legal-Hold": "ON", + "X-Amz-Object-Lock-Mode": "GOVERNANCE", + "X-Amz-Object-Lock-Retain-Until-Date": "2026-12-31T00:00:00Z", + }, + }, } for i, tt := range tests {