Files
seaweedfs/weed/s3api/s3_metadata_util.go
T
b0e6f7a096 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>
2026-10-24 07:04:01 -07:00

114 lines
3.8 KiB
Go

package s3api
import (
"net/http"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// ParseS3Metadata extracts S3-specific metadata from HTTP request headers
// This includes: storage class, tags, user metadata, SSE headers, and ACL headers
// Used by S3 API handlers to prepare metadata before saving to filer
// Returns an S3 error code if tag parsing fails
func ParseS3Metadata(r *http.Request, existing map[string][]byte, isReplace bool) (metadata map[string][]byte, errCode s3err.ErrorCode) {
metadata = make(map[string][]byte)
// Copy existing metadata unless replacing
if !isReplace {
for k, v := range existing {
metadata[k] = v
}
}
// Storage class
if sc := r.Header.Get(s3_constants.AmzStorageClass); sc != "" {
metadata[s3_constants.AmzStorageClass] = []byte(sc)
}
// Content-Encoding (standard HTTP header used by S3)
if ce := storedContentEncoding(r.Header.Values("Content-Encoding")); ce != "" {
metadata["Content-Encoding"] = []byte(ce)
}
// Other Standard HTTP headers defined in https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
standardHeaders := []string{
"Cache-Control",
"Content-Disposition",
"Content-Language",
"Expires",
}
for _, header := range standardHeaders {
if value := r.Header.Get(header); value != "" {
metadata[header] = []byte(value)
}
}
// Do NOT persist Response-Content-Disposition: it is a GET-only
// presigned-download override and must not be stored as upload metadata.
// Object tagging
if tags := r.Header.Get(s3_constants.AmzObjectTagging); tags != "" {
tagSet, err := parseTagsHeader(tags)
if err != nil {
glog.Warningf("Invalid S3 tagging header: %v", err)
return nil, s3err.ErrInvalidTag
}
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)
}
}
// User-defined metadata (x-amz-meta-* headers)
for header, values := range r.Header {
if strings.HasPrefix(header, s3_constants.AmzUserMetaPrefix) {
// Go's HTTP server canonicalizes headers (e.g., x-amz-meta-foo → X-Amz-Meta-Foo)
// Per HTTP and S3 spec: multiple header values are concatenated with commas
// This ensures no metadata is lost when clients send duplicate header names
metadata[header] = []byte(strings.Join(values, ","))
}
}
// SSE-C headers
if algorithm := r.Header.Get(s3_constants.AmzServerSideEncryptionCustomerAlgorithm); algorithm != "" {
metadata[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte(algorithm)
}
if keyMD5 := r.Header.Get(s3_constants.AmzServerSideEncryptionCustomerKeyMD5); keyMD5 != "" {
// Store as-is; SSE-C MD5 is base64 and case-sensitive
metadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(keyMD5)
}
return metadata, s3err.ErrNone
}
// storedContentEncoding returns the Content-Encoding to keep with an object,
// from the values of the request's Content-Encoding fields, which it combines
// as one list. aws-chunked names the SigV4 streaming framing of the request
// body, which is decoded on upload, so S3 does not store it: "gzip,
// aws-chunked" is kept as "gzip", and "aws-chunked" alone as no
// Content-Encoding at all.
func storedContentEncoding(values []string) string {
value := strings.Join(values, ", ")
var kept []string
chunked := false
for _, encoding := range strings.Split(value, ",") {
encoding = strings.TrimSpace(encoding)
switch {
case strings.EqualFold(encoding, "aws-chunked"):
chunked = true
case encoding != "":
kept = append(kept, encoding)
}
}
if !chunked {
return value
}
return strings.Join(kept, ", ")
}