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 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-04 09:43:48 -08:00
1 parent 0742d316aa
commit c437791991
2 files changed
+76

No files matched your search

@@ -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 {
@@ -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"}}))
}