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 <noreply@anthropic.com>

* s3: test POST Object request headers against signed form fields

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-12 09:28:33 -08:00
1 parent 249f04e63a
commit 4cba4eb7bf
2 files changed
+88 -10

No files matched your search

+10 -10
View File
@@ -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 {
@@ -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)
}
})
}