mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 23:10:41 +02:00
s3: allow up to 50 tags in a bucket tag set (#12030)
* s3: allow up to 50 tags in a bucket tag set PutBucketTagging validated the tag set with the object tag rules, which allow 10 tags. AWS allows a bucket up to 50 tags, so tag sets of 11 to 50 tags were rejected with InvalidTag. Validate bucket tags against 50 and keep the limit of 10 for objects and S3 Tables. A tag set that repeats a key is rejected with InvalidTag, as AWS does, instead of keeping one of the values, so 51 tags with a repeated key no longer pass as 50. * s3: reject duplicate tag keys in PutObjectTagging The tag set collapses to a map, so a repeated key was silently kept once.\nMatch the bucket tagging check and answer InvalidTag instead. * s3: reject empty tag keys AWS requires a tag key to be at least one character; the shared\nvalidator only checked the 128-char upper bound. * s3: reject duplicate tag keys in the tagging header parser parseTagsHeader (the x-amz-tagging path used by CopyObject with TaggingDirective=REPLACE) silently kept the last value when a key repeated, while the PutObject and multipart header paths and the new XML checks return InvalidTag for the same input. A repeated key after URL decoding now fails the same way. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: validate x-amz-tagging tags on PutObject before storing the entry The header path only rejected duplicate keys, so an upload could store more than ten tags or keys and values that fail S3 tag rules. Run the parsed set through ValidateTags like the XML tagging handlers do. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: validate x-amz-tagging tags on multipart initiate ParseS3Metadata stored header tags with no tag-set validation, the same gap putToFiler had. Multipart uploads now reject oversize or malformed tag sets with InvalidTag. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: count tag length in characters, not bytes * s3: validate object tags before uploading chunks * s3: reuse parseTagsHeader and drop raw header from warnings --------- Co-authored-by: Wleeaf <179125857+wleeaf@users.noreply.github.com> Co-authored-by: user.email <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
9 files changed
+215
-43
No files matched your search
@@ -2,7 +2,6 @@ package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
@@ -52,25 +51,16 @@ func ParseS3Metadata(r *http.Request, existing map[string][]byte, isReplace bool
|
||||
|
||||
// Object tagging
|
||||
if tags := r.Header.Get(s3_constants.AmzObjectTagging); tags != "" {
|
||||
// Use url.ParseQuery for robust parsing and automatic URL decoding
|
||||
parsedTags, err := url.ParseQuery(tags)
|
||||
tagSet, err := parseTagsHeader(tags)
|
||||
if err != nil {
|
||||
// Return proper S3 error instead of silently dropping tags
|
||||
glog.Warningf("Invalid S3 tag format in header '%s': %v", tags, err)
|
||||
glog.Warningf("Invalid S3 tagging header: %v", err)
|
||||
return nil, s3err.ErrInvalidTag
|
||||
}
|
||||
|
||||
// Validate: S3 spec does not allow duplicate tag keys
|
||||
for key, values := range parsedTags {
|
||||
if len(values) > 1 {
|
||||
glog.Warningf("Duplicate tag key '%s' in header '%s'", key, tags)
|
||||
return nil, s3err.ErrInvalidTag
|
||||
}
|
||||
// Tag value can be an empty string but not nil
|
||||
value := ""
|
||||
if len(values) > 0 {
|
||||
value = values[0]
|
||||
}
|
||||
if err := ValidateTags(tagSet); err != nil {
|
||||
glog.Warningf("Invalid tags (%d): %v", len(tagSet), err)
|
||||
return nil, s3err.ErrInvalidTag
|
||||
}
|
||||
for key, value := range tagSet {
|
||||
metadata[s3_constants.AmzObjectTagging+"-"+key] = []byte(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,14 @@ func (s3a *S3ApiServer) PutBucketTaggingHandler(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
tags := tagging.ToTags()
|
||||
// A key may appear only once in a tag set; the map keeps one of them.
|
||||
if len(tags) != len(tagging.TagSet.Tag) {
|
||||
glog.Errorf("PutBucketTagging duplicate tag keys %s", r.URL)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidTag)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate tags using existing validation
|
||||
err = ValidateTags(tags)
|
||||
err = ValidateBucketTags(tags)
|
||||
if err != nil {
|
||||
glog.Errorf("PutBucketTagging ValidateTags error %s: %v", r.URL, err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidTag)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// A bucket's tag set holds up to 50 tags, unlike an object's 10, and no key
|
||||
// twice.
|
||||
func TestPutBucketTaggingTagCountLimit(t *testing.T) {
|
||||
const bucket = "tag-limit-bucket"
|
||||
for _, tc := range []struct {
|
||||
tags int
|
||||
repeatKey bool // the last tag repeats the first tag's key
|
||||
emptyKey bool // the first tag has an empty key
|
||||
wantCode int
|
||||
}{
|
||||
{tags: 10, wantCode: http.StatusOK},
|
||||
{tags: 11, wantCode: http.StatusOK},
|
||||
{tags: 50, wantCode: http.StatusOK},
|
||||
{tags: 51, wantCode: http.StatusBadRequest},
|
||||
{tags: 2, repeatKey: true, wantCode: http.StatusBadRequest},
|
||||
{tags: 51, repeatKey: true, wantCode: http.StatusBadRequest},
|
||||
{tags: 1, emptyKey: true, wantCode: http.StatusBadRequest},
|
||||
} {
|
||||
name := fmt.Sprintf("%d tags", tc.tags)
|
||||
if tc.repeatKey {
|
||||
name += " with a repeated key"
|
||||
}
|
||||
if tc.emptyKey {
|
||||
name += " with an empty key"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
filer := &fakeTxnFiler{}
|
||||
s3a := newHeadBucketTestServer(t, filer)
|
||||
s3a.iam = &IdentityAccessManagement{isAuthEnabled: true}
|
||||
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
||||
s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket})
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString("<Tagging><TagSet>")
|
||||
for i := 0; i < tc.tags; i++ {
|
||||
key := fmt.Sprintf("key%d", i)
|
||||
if tc.repeatKey && i == tc.tags-1 {
|
||||
key = "key0"
|
||||
}
|
||||
if tc.emptyKey && i == 0 {
|
||||
key = ""
|
||||
}
|
||||
fmt.Fprintf(&body, "<Tag><Key>%s</Key><Value>value%d</Value></Tag>", key, i)
|
||||
}
|
||||
body.WriteString("</TagSet></Tagging>")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/"+bucket+"?tagging", strings.NewReader(body.String()))
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": bucket})
|
||||
rr := httptest.NewRecorder()
|
||||
s3a.PutBucketTaggingHandler(rr, req)
|
||||
|
||||
if rr.Code != tc.wantCode {
|
||||
t.Fatalf("status = %d, want %d; body: %s", rr.Code, tc.wantCode, rr.Body.String())
|
||||
}
|
||||
if tc.wantCode != http.StatusOK {
|
||||
if !strings.Contains(rr.Body.String(), "<Code>InvalidTag</Code>") {
|
||||
t.Fatalf("body = %s, want InvalidTag", rr.Body.String())
|
||||
}
|
||||
if filer.lastReq != nil {
|
||||
t.Fatalf("rejected tag set was written to the filer")
|
||||
}
|
||||
return
|
||||
}
|
||||
if filer.lastReq == nil || len(filer.lastReq.Mutations) != 1 {
|
||||
t.Fatalf("expected one bucket metadata write, got %+v", filer.lastReq)
|
||||
}
|
||||
var stored s3_pb.BucketMetadata
|
||||
if err := proto.Unmarshal(filer.lastReq.Mutations[0].Content, &stored); err != nil {
|
||||
t.Fatalf("decode stored bucket metadata: %v", err)
|
||||
}
|
||||
if len(stored.Tags) != tc.tags {
|
||||
t.Fatalf("stored %d tags, want %d", len(stored.Tags), tc.tags)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -527,6 +527,22 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader
|
||||
if !s3_constants.IsValidBucketName(bucket) || (object != "" && !s3_constants.IsValidObjectKey(object)) {
|
||||
return "", s3err.ErrInvalidRequest, SSEResponseMetadata{}
|
||||
}
|
||||
|
||||
// Reject invalid tags before any chunks are uploaded so a failed request
|
||||
// leaves nothing behind on the volume servers.
|
||||
var tagSet map[string]string
|
||||
if tagging := r.Header.Get(s3_constants.AmzObjectTagging); tagging != "" {
|
||||
parsed, err := parseTagsHeader(tagging)
|
||||
if err != nil {
|
||||
glog.Warningf("putToFiler: invalid tagging header for %s: %v", filePath, err)
|
||||
return "", s3err.ErrInvalidTag, SSEResponseMetadata{}
|
||||
}
|
||||
if err := ValidateTags(parsed); err != nil {
|
||||
glog.Warningf("putToFiler: invalid tags (%d) for %s: %v", len(parsed), filePath, err)
|
||||
return "", s3err.ErrInvalidTag, SSEResponseMetadata{}
|
||||
}
|
||||
tagSet = parsed
|
||||
}
|
||||
// NEW OPTIMIZATION: Write directly to volume servers, bypassing filer proxy
|
||||
// This eliminates the filer proxy overhead for PUT operations
|
||||
// Note: filePath is now passed directly instead of URL (no parsing needed)
|
||||
@@ -938,26 +954,12 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader
|
||||
entry.Extended[s3_constants.AmzStorageClass] = []byte(sc)
|
||||
}
|
||||
|
||||
// Parse and store object tags from X-Amz-Tagging header
|
||||
// Fix for GitHub issue #7589: Tags sent during object upload were not being stored
|
||||
if tagging := r.Header.Get(s3_constants.AmzObjectTagging); tagging != "" {
|
||||
parsedTags, err := url.ParseQuery(tagging)
|
||||
if err != nil {
|
||||
glog.Warningf("putToFiler: Invalid S3 tag format in header '%s': %v", tagging, err)
|
||||
return "", s3err.ErrInvalidTag, SSEResponseMetadata{}
|
||||
}
|
||||
for key, values := range parsedTags {
|
||||
if len(values) > 1 {
|
||||
glog.Warningf("putToFiler: Duplicate tag key '%s' in header", key)
|
||||
return "", s3err.ErrInvalidTag, SSEResponseMetadata{}
|
||||
}
|
||||
value := ""
|
||||
if len(values) > 0 {
|
||||
value = values[0]
|
||||
}
|
||||
entry.Extended[s3_constants.AmzObjectTagging+"-"+key] = []byte(value)
|
||||
}
|
||||
glog.V(3).Infof("putToFiler: stored %d tags from X-Amz-Tagging header", len(parsedTags))
|
||||
// Store the tags validated before the chunk upload
|
||||
for key, value := range tagSet {
|
||||
entry.Extended[s3_constants.AmzObjectTagging+"-"+key] = []byte(value)
|
||||
}
|
||||
if len(tagSet) > 0 {
|
||||
glog.V(3).Infof("putToFiler: stored %d tags from X-Amz-Tagging header", len(tagSet))
|
||||
}
|
||||
|
||||
storeSSEMetadata(entry, sseResult)
|
||||
|
||||
@@ -139,6 +139,12 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
tags := tagging.ToTags()
|
||||
// A key may appear only once in a tag set; the map keeps one of them.
|
||||
if len(tags) != len(tagging.TagSet.Tag) {
|
||||
glog.Errorf("PutObjectTaggingHandler duplicate tag keys %s", r.URL)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidTag)
|
||||
return
|
||||
}
|
||||
err = ValidateTags(tags)
|
||||
if err != nil {
|
||||
glog.Errorf("PutObjectTaggingHandler ValidateTags error %s: %v", r.URL, err)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func TestPutObjectTaggingRejectsDuplicateKeys(t *testing.T) {
|
||||
s3a := newHeadBucketTestServer(t, &fakeTxnFiler{})
|
||||
body := "<Tagging><TagSet>" +
|
||||
"<Tag><Key>a</Key><Value>1</Value></Tag>" +
|
||||
"<Tag><Key>a</Key><Value>2</Value></Tag>" +
|
||||
"</TagSet></Tagging>"
|
||||
req := httptest.NewRequest(http.MethodPut, "/b/o?tagging", strings.NewReader(body))
|
||||
req = mux.SetURLVars(req, map[string]string{"bucket": "b", "object": "o"})
|
||||
rr := httptest.NewRecorder()
|
||||
s3a.PutObjectTaggingHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body: %s", rr.Code, http.StatusBadRequest, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "<Code>InvalidTag</Code>") {
|
||||
t.Fatalf("body = %s, want InvalidTag", rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
@@ -372,17 +373,25 @@ func arnPartitionForRegion(region string) string {
|
||||
|
||||
// ValidateTags validates tags for S3 Tables.
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
if len(tags) > 10 {
|
||||
return fmt.Errorf("validate tags: %d tags more than 10", len(tags))
|
||||
return ValidateTagsWithLimit(tags, 10)
|
||||
}
|
||||
|
||||
// ValidateTagsWithLimit validates a tag set that may hold up to maxTags tags.
|
||||
func ValidateTagsWithLimit(tags map[string]string, maxTags int) error {
|
||||
if len(tags) > maxTags {
|
||||
return fmt.Errorf("validate tags: %d tags more than %d", len(tags), maxTags)
|
||||
}
|
||||
for k, v := range tags {
|
||||
if len(k) > 128 {
|
||||
if len(k) == 0 {
|
||||
return fmt.Errorf("validate tags: empty tag key")
|
||||
}
|
||||
if utf8.RuneCountInString(k) > 128 {
|
||||
return fmt.Errorf("validate tags: tag key longer than 128")
|
||||
}
|
||||
if !tagPattern.MatchString(k) {
|
||||
return fmt.Errorf("validate tags key %s error, incorrect key", k)
|
||||
}
|
||||
if len(v) > 256 {
|
||||
if utf8.RuneCountInString(v) > 256 {
|
||||
return fmt.Errorf("validate tags: tag value longer than 256")
|
||||
}
|
||||
if !tagPattern.MatchString(v) {
|
||||
|
||||
@@ -63,6 +63,9 @@ func parseTagsHeader(tags string) (map[string]string, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode tag key '%s': %w", key, err)
|
||||
}
|
||||
if _, exists := parsedTags[decodedKey]; exists {
|
||||
return nil, fmt.Errorf("duplicate tag key '%s'", decodedKey)
|
||||
}
|
||||
if !hasValue {
|
||||
parsedTags[decodedKey] = ""
|
||||
continue
|
||||
@@ -79,3 +82,11 @@ func parseTagsHeader(tags string) (map[string]string, error) {
|
||||
func ValidateTags(tags map[string]string) error {
|
||||
return s3tables.ValidateTags(tags)
|
||||
}
|
||||
|
||||
// maxBucketTags is how many tags a bucket's tag set can hold; an object's
|
||||
// tag set holds 10.
|
||||
const maxBucketTags = 50
|
||||
|
||||
func ValidateBucketTags(tags map[string]string) error {
|
||||
return s3tables.ValidateTagsWithLimit(tags, maxBucketTags)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -62,6 +63,18 @@ func TestParseTagsHeader(t *testing.T) {
|
||||
expected: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "duplicate key",
|
||||
input: "key1=value1&key1=value2",
|
||||
expected: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "encoded duplicate key",
|
||||
input: "key1=value1&%6bey1=value2",
|
||||
expected: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "plus signs and equals in values",
|
||||
input: "formula=a%2Bb%3Dc&normal=test",
|
||||
@@ -120,3 +133,19 @@ func TestParseTagsHeader(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTagsUnicodeLength(t *testing.T) {
|
||||
// S3 counts tag length in characters, not bytes
|
||||
multibyteKey := strings.Repeat("中", 128)
|
||||
multibyteValue := strings.Repeat("中", 256)
|
||||
|
||||
if err := ValidateTags(map[string]string{multibyteKey: multibyteValue}); err != nil {
|
||||
t.Errorf("Expected 128-char key and 256-char value to pass, got %v", err)
|
||||
}
|
||||
if err := ValidateTags(map[string]string{strings.Repeat("中", 129): "v"}); err == nil {
|
||||
t.Error("Expected 129-char key to fail")
|
||||
}
|
||||
if err := ValidateTags(map[string]string{"k": strings.Repeat("中", 257)}); err == nil {
|
||||
t.Error("Expected 257-char value to fail")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user