From 4cba4eb7bfc3a9083cf2269cec906d7832627757 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 12 Nov 2026 09:28:33 -0800 Subject: [PATCH] s3: ignore unsigned object headers on POST Object (#12585) * s3: ignore unsigned object headers on POST Object The POST policy signs form fields only, but the upload read tagging, user metadata, storage class, object lock and the stored content headers from the request headers as well. Drop them before the form fields are applied. Co-Authored-By: Claude Opus 5.5 * s3: test POST Object request headers against signed form fields Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../s3api/s3api_object_handlers_postpolicy.go | 20 ++--- ...i_object_handlers_postpolicy_authz_test.go | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/weed/s3api/s3api_object_handlers_postpolicy.go b/weed/s3api/s3api_object_handlers_postpolicy.go index 4c18e34ca1..a316a9b7da 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy.go +++ b/weed/s3api/s3api_object_handlers_postpolicy.go @@ -10,6 +10,7 @@ import ( "mime/multipart" "net/http" "net/url" + "slices" "strings" "github.com/dustin/go-humanize" @@ -307,9 +308,11 @@ var postPolicyReservedFormFields = map[string]struct{}{ // skipped. The acl form field is translated to the X-Amz-Acl header to match // how AWS promotes the form value to the underlying PUT. func applyPostPolicyFormHeaders(r *http.Request, formValues http.Header) { - // The policy signs form fields only, so the upload ACL comes from the form. - for _, header := range postAclHeaders { - r.Header.Del(header) + // The policy signs form fields only, so object settings come from the form. + for k := range r.Header { + if ck := http.CanonicalHeaderKey(k); strings.HasPrefix(ck, "X-Amz-") || slices.Contains(postObjectHeaders, ck) || ck == "Response-Content-Disposition" { + delete(r.Header, k) + } } for k := range formValues { if _, reserved := postPolicyReservedFormFields[k]; reserved { @@ -318,18 +321,15 @@ func applyPostPolicyFormHeaders(r *http.Request, formValues http.Header) { switch { case k == "Acl": r.Header.Set(s3_constants.AmzCannedAcl, formValues.Get(k)) - case k == "Cache-Control", - k == "Expires", - k == "Content-Disposition", - k == "Content-Encoding", - k == "Content-Language": - r.Header.Set(k, formValues.Get(k)) - case strings.HasPrefix(k, "X-Amz-"): + case slices.Contains(postObjectHeaders, k), strings.HasPrefix(k, "X-Amz-"): r.Header.Set(k, formValues.Get(k)) } } } +// postObjectHeaders are the standard headers the upload stores on the object. +var postObjectHeaders = []string{"Cache-Control", "Expires", "Content-Disposition", "Content-Encoding", "Content-Language"} + // validHeaderValues rejects form values carrying control characters, which a // real PUT could not send and which would corrupt the volume upload framing. func validHeaderValues(h http.Header) bool { diff --git a/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go b/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go index b60fbe4f33..17c9504396 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go +++ b/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go @@ -757,3 +757,81 @@ func TestPostPolicyBucketHandlerRejectsConflictingAclFields(t *testing.T) { filer.mu.Unlock() } } + +// Object settings sent as request headers are outside the signed policy, so +// only the matching form fields reach the stored object. +func TestPostPolicyBucketHandlerIgnoresUnsignedHeaders(t *testing.T) { + const key = "uploads/header.txt" + upload := func(t *testing.T, fields, headers map[string]string) (*httptest.ResponseRecorder, *filer_pb.Entry) { + filer := &ambiguousPutFiler{ + volume: startFakeVolumeServer(t), + entries: map[string]*filer_pb.Entry{"/buckets/" + postAuthzBucket: { + Name: postAuthzBucket, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{}, + Extended: map[string][]byte{s3_constants.ExtAmzOwnerKey: []byte("000000000000")}, + }}, + apply: true, + } + s3a := newPostAutoCreateServer(t, filer) + req := newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, fields) + for name, value := range headers { + req.Header.Set(name, value) + } + rec := httptest.NewRecorder() + s3a.PostPolicyBucketHandler(rec, req) + filer.mu.Lock() + defer filer.mu.Unlock() + return rec, filer.entries["/buckets/"+postAuthzBucket+"/"+key] + } + settings := map[string]string{ + s3_constants.AmzObjectTagging: "scan=clean", + "X-Amz-Meta-Role": "from-form-holder", + s3_constants.AmzStorageClass: "STANDARD_IA", + "Cache-Control": "max-age=60", + "Content-Disposition": "inline", + "Expires": "Wed, 21 Oct 2026 07:28:00 GMT", + } + stored := map[string]string{ + s3_constants.AmzObjectTaggingPrefix + "scan": "clean", + "X-Amz-Meta-Role": "from-form-holder", + s3_constants.AmzStorageClass: "STANDARD_IA", + "Cache-Control": "max-age=60", + "Content-Disposition": "inline", + "Expires": "Wed, 21 Oct 2026 07:28:00 GMT", + } + + t.Run("request headers", func(t *testing.T) { + headers := map[string]string{"Content-Language": "fr", "Response-Content-Disposition": "inline"} + for k, v := range settings { + headers[k] = v + } + rec, entry := upload(t, nil, headers) + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + require.NotNil(t, entry) + for k := range stored { + assert.Empty(t, entry.Extended[k], k) + } + assert.Empty(t, entry.Extended["Content-Language"]) + }) + t.Run("object lock request headers", func(t *testing.T) { + rec, entry := upload(t, nil, map[string]string{ + s3_constants.AmzObjectLockMode: "COMPLIANCE", + s3_constants.AmzObjectLockRetainUntilDate: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + s3_constants.AmzObjectLockLegalHold: "ON", + }) + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + require.NotNil(t, entry) + assert.Empty(t, entry.Extended[s3_constants.ExtObjectLockModeKey]) + assert.Empty(t, entry.Extended[s3_constants.ExtRetentionUntilDateKey]) + assert.Empty(t, entry.Extended[s3_constants.ExtLegalHoldKey]) + }) + t.Run("signed form fields", func(t *testing.T) { + rec, entry := upload(t, settings, nil) + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + require.NotNil(t, entry) + for k, v := range stored { + assert.Equal(t, v, string(entry.Extended[k]), k) + } + }) +}