diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go index ab01cfda36..4c16300cd5 100644 --- a/weed/s3api/s3api_object_handlers.go +++ b/weed/s3api/s3api_object_handlers.go @@ -2401,23 +2401,29 @@ func (s3a *S3ApiServer) setResponseHeaders(w http.ResponseWriter, r *http.Reques // Set custom headers from entry.Extended (user metadata) // Use direct map assignment to preserve original header casing (matches proxy behavior) if entry.Extended != nil { + extAmzPrefixLower := strings.ToLower(s3_constants.ExtAmzPrefix) + taggingPrefixLower := strings.ToLower(s3_constants.AmzObjectTaggingPrefix) for k, v := range entry.Extended { - // Skip internal SeaweedFS headers - if !strings.HasPrefix(k, "xattr-") && !s3_constants.IsSeaweedFSInternalHeader(k) { - // Support backward compatibility: migrate old non-canonical format to canonical format - // OLD: "x-amz-meta-foo" → NEW: "X-Amz-Meta-foo" (preserving suffix case) - headerKey := k - if len(k) >= 11 && strings.EqualFold(k[:11], "x-amz-meta-") { - // Normalize to AWS S3 format: "X-Amz-Meta-" prefix with lowercase suffix - // AWS S3 returns user metadata with the suffix in lowercase - suffix := k[len("x-amz-meta-"):] - headerKey = s3_constants.AmzUserMetaPrefix + strings.ToLower(suffix) - if glog.V(4) && k != headerKey { - glog.Infof("Normalizing user metadata header %q to %q in response", k, headerKey) - } - } - w.Header()[headerKey] = []string{string(v)} + // Skip internal SeaweedFS headers, and the stored tag/ACL/lock + // metadata that belongs behind its own API (GetObjectTagging, + // GetObjectAcl, GetObjectRetention) instead of every reader. + lowerKey := strings.ToLower(k) + if strings.HasPrefix(lowerKey, "xattr-") || strings.HasPrefix(lowerKey, extAmzPrefixLower) || strings.HasPrefix(lowerKey, taggingPrefixLower) || s3_constants.IsSeaweedFSInternalHeader(lowerKey) { + continue } + // Support backward compatibility: migrate old non-canonical format to canonical format + // OLD: "x-amz-meta-foo" → NEW: "X-Amz-Meta-foo" (preserving suffix case) + headerKey := k + if len(k) >= 11 && strings.EqualFold(k[:11], "x-amz-meta-") { + // Normalize to AWS S3 format: "X-Amz-Meta-" prefix with lowercase suffix + // AWS S3 returns user metadata with the suffix in lowercase + suffix := k[len("x-amz-meta-"):] + headerKey = s3_constants.AmzUserMetaPrefix + strings.ToLower(suffix) + if glog.V(4) && k != headerKey { + glog.Infof("Normalizing user metadata header %q to %q in response", k, headerKey) + } + } + w.Header()[headerKey] = []string{string(v)} } } diff --git a/weed/s3api/s3api_object_handlers_response_headers_test.go b/weed/s3api/s3api_object_handlers_response_headers_test.go new file mode 100644 index 0000000000..d2d010c5c2 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_response_headers_test.go @@ -0,0 +1,66 @@ +package s3api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// GetObject/HeadObject must expose only response headers AWS defines: user +// metadata, standard entity headers and x-amz-tagging-count. Tag values live +// behind GetObjectTagging and ACL/owner/lock state behind its own APIs; the +// generic Extended echo must not hand them to any caller that can read the +// object. +func TestSetResponseHeadersFiltersInternalExtendedKeys(t *testing.T) { + s3a := &S3ApiServer{} + entry := &filer_pb.Entry{ + Name: "object", + Attributes: &filer_pb.FuseAttributes{Mtime: 100}, + Extended: map[string][]byte{ + s3_constants.AmzObjectTaggingPrefix + "project": []byte("alpha"), + s3_constants.AmzObjectTaggingPrefix + "owner": []byte("y"), + "x-amz-tagging-sneaky": []byte("z"), + "seaweed-x-amz-acl": []byte("z"), + s3_constants.ExtAmzAclKey: []byte(`[{"Permission":"FULL_CONTROL"}]`), + s3_constants.ExtAmzOwnerKey: []byte("admin"), + s3_constants.ExtVersionIdKey: []byte("version-1"), + s3_constants.ExtObjectLockModeKey: []byte("COMPLIANCE"), + s3_constants.ExtETagKey: []byte("d41d8cd98f00b204e9800998ecf8427e"), + s3_constants.SeaweedFSWriteId: []byte("write-id"), + "X-Amz-Meta-Foo": []byte("bar"), + "Cache-Control": []byte("no-cache"), + "xattr-user.note": []byte("n"), + }, + } + + for _, method := range []string{http.MethodGet, http.MethodHead} { + w := httptest.NewRecorder() + s3a.setResponseHeaders(w, httptest.NewRequest(method, "/bucket/object", nil), entry, 8) + + for key := range w.Header() { + lowerKey := strings.ToLower(key) + if lowerKey == s3_constants.AmzTagCount { + continue + } + if strings.HasPrefix(lowerKey, strings.ToLower(s3_constants.ExtAmzPrefix)) { + t.Errorf("%s: internal header %q leaked into response", method, key) + } + if strings.HasPrefix(lowerKey, strings.ToLower(s3_constants.AmzObjectTaggingPrefix)) { + t.Errorf("%s: object tag value %q leaked into response", method, key) + } + } + if got := w.Header().Get(s3_constants.AmzTagCount); got != "2" { + t.Errorf("%s: tag count = %q, want 2", method, got) + } + if got := w.Header()["X-Amz-Meta-foo"]; len(got) != 1 || got[0] != "bar" { + t.Errorf("%s: user metadata dropped, X-Amz-Meta-foo = %v", method, got) + } + if got := w.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("%s: Cache-Control = %q", method, got) + } + } +}