s3api: stop echoing stored tags and internal attributes on GET/HEAD (#12053)

* s3api: stop echoing stored tags and internal attributes on GET/HEAD

setResponseHeaders copied every entry.Extended key into the response,
only filtering xattr- and x-seaweedfs-. Stored object tags
(X-Amz-Tagging-*) and the gateway's own attributes (Seaweed-X-Amz-Acl,
-Owner, -Version-Id, -Object-Lock-Mode, ...) therefore leaked to any
caller holding s3:GetObject, and to anonymous readers on public-read
objects, without s3:GetObjectTagging or READ_ACP.

Tag count, user metadata, storage class and the real S3 headers
(x-amz-version-id, x-amz-object-lock-*) are unaffected.

* s3api: match stored tag and internal attribute keys case-insensitively
This commit is contained in:
Chris Lu authored and GitHub committed 2026-10-25 06:05:06 -07:00
1 parent 52d91008ac
commit 403d51631b
2 files changed
+87 -15

No files matched your search

+21 -15
View File
@@ -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)}
}
}
@@ -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)
}
}
}