From c43779199112466154306f51e18b56d19c63f8b9 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 4 Nov 2026 09:43:48 -0800 Subject: [PATCH] s3: reject POST Object header fields with control characters (#12400) A Content-Type form field such as "\n" was copied verbatim into the multipart part header sent to the volume server, which broke the framing and surfaced as a Content-MD5 mismatch and a 500. Form fields promoted to request headers must be valid header values, as on a PUT; otherwise answer 400 MalformedPOSTRequest. Co-authored-by: Claude Opus 5.5 --- .../s3api/s3api_object_handlers_postpolicy.go | 18 ++++++ .../s3api_object_handlers_postpolicy_test.go | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/weed/s3api/s3api_object_handlers_postpolicy.go b/weed/s3api/s3api_object_handlers_postpolicy.go index 69139ce72c..b95f18fe6f 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy.go +++ b/weed/s3api/s3api_object_handlers_postpolicy.go @@ -19,6 +19,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/s3api/policy" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "golang.org/x/net/http/httpguts" ) func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.Request) { @@ -145,6 +146,10 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R // Forward validated POST form fields to the underlying PUT as headers. applyPostPolicyFormHeaders(r, formValues) + if !validHeaderValues(r.Header) { + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest) + return + } // Authorize the object like the PUT path. if errCode := s3a.iam.AuthorizeObjectWrite(r, identity, bucket, object); errCode != s3err.ErrNone { @@ -312,6 +317,19 @@ func applyPostPolicyFormHeaders(r *http.Request, formValues http.Header) { } } +// 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 { + for _, values := range h { + for _, v := range values { + if !httpguts.ValidHeaderFieldValue(v) { + return false + } + } + } + return true +} + // postRequestsAcl reports whether the request carries ACL headers, whether // promoted from the POST form fields or supplied on the request itself. func postRequestsAcl(r *http.Request) bool { diff --git a/weed/s3api/s3api_object_handlers_postpolicy_test.go b/weed/s3api/s3api_object_handlers_postpolicy_test.go index b8e65d6c8d..eee74fd8d8 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy_test.go +++ b/weed/s3api/s3api_object_handlers_postpolicy_test.go @@ -733,3 +733,61 @@ func TestPostPolicyBucketHandler_AuthDisabledSkipsSignature(t *testing.T) { assert.Contains(t, rec.Body.String(), "Policy Condition failed", "the policy check must still run, body: %s", rec.Body.String()) } + +func TestPostPolicyBucketHandler_RejectsControlCharsInHeaderFields(t *testing.T) { + const testBucket = "test-bucket" + + tests := []struct { + name string + field string + value string + }{ + {"content-type newline", "Content-Type", "\n"}, + {"content-type crlf", "Content-Type", "text/plain\r\nContent-MD5: x"}, + {"content-disposition newline", "Content-Disposition", "inline\n"}, + {"metadata newline", "x-amz-meta-foo", "a\nb"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iam := &IdentityAccessManagement{ + hashes: make(map[string]*sync.Pool), + hashCounters: make(map[string]*int32), + } + assert.NoError(t, iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{})) + + s3a := &S3ApiServer{ + option: &S3ApiServerOption{BucketsPath: "/buckets"}, + iam: iam, + } + s3a.bucketRegistry = NewBucketRegistry(s3a) + s3a.bucketRegistry.setMetadataCache(&BucketMetaData{Name: testBucket, IsTableBucket: false}) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + assert.NoError(t, writer.WriteField("key", "object.txt")) + assert.NoError(t, writer.WriteField(tt.field, tt.value)) + filePart, err := writer.CreateFormFile("file", "payload.txt") + assert.NoError(t, err) + _, err = filePart.Write([]byte("contents")) + assert.NoError(t, err) + assert.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/"+testBucket, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req = mux.SetURLVars(req, map[string]string{"bucket": testBucket}) + rec := httptest.NewRecorder() + + s3a.PostPolicyBucketHandler(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "MalformedPOSTRequest") + }) + } +} + +func TestValidHeaderValues(t *testing.T) { + assert.True(t, validHeaderValues(http.Header{"Content-Type": {"text/plain; charset=utf-8"}, "X-Amz-Meta-Name": {"café\tbar"}})) + assert.False(t, validHeaderValues(http.Header{"Content-Type": {"\r\n"}})) + assert.False(t, validHeaderValues(http.Header{"Cache-Control": {"no-cache\x00"}})) +}