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) + } + }) +}