Files
seaweedfs/weed/s3api/request_body_test.go
T
Chris LuandClaude Opus 5.5 6388dfca9b s3api: verify signed request bodies against the signed payload hash (#12577)
* s3api: verify signed request bodies against the signed payload hash

A SigV4 signature covers the declared x-amz-content-sha256, but only
getRequestDataReader (PutObject, UploadPart) compared the body with it.
Every other handler parsed the body as received, so a signed request,
DeleteObjects for example, could be replayed with a different body.

After a V4 signature verifies, wrap the body so it is handed out only
once all of it has been read and matched against the hash the signature
covered; a decoder that stops reading early never sees unverified bytes.
getRequestDataReader unwraps it and keeps verifying uploads as they
stream.

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

* s3api: answer XAmzContentSHA256Mismatch for refused DeleteObjects bodies

DeleteObjects answered a body that failed its payload-hash check with
InternalError and CompleteMultipartUpload with MalformedXML. Name the
check that failed instead, as PutObject does.

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

* s3api: check Content-MD5 on signed request bodies

Signature V2 signs Content-MD5 rather than a payload hash, and a V4
request sent with UNSIGNED-PAYLOAD may still sign it, but bodies read
outside the upload path never compared it with the body. Check it with
the payload hash once a V2 or V4 signature verifies: BadDigest on a
mismatch, InvalidDigest when it is malformed.

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

* s3api: verify presigned uploads against a signed payload hash

A presigned V4 URL can sign X-Amz-Content-Sha256 as a query parameter,
but getRequestDataReader compared upload bodies only with the header of
that name, so the signed hash went unchecked. Verify uploads against the
hash the signature covered.

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

* s3api: check Content-MD5 on bodies buffered for their payload hash

IAM and other non-S3 requests signed without x-amz-content-sha256 have
their body buffered and hashed during signature verification. That
buffer counted as already bound, so their Content-MD5 was never checked.

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

* s3api: check Content-MD5 on directory marker bodies

A PUT of a key ending in "/" stores its body on the directory entry
without going through putToFiler, so its Content-MD5 was never compared
with the body. Answer BadDigest on a mismatch, as putToFiler does.

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

* s3api: wrap and unwrap signed bodies through helpers

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:17:09 -08:00

266 lines
9.7 KiB
Go

package s3api
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadRequestBodyLeavesOversizedBodyDrainable(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Body = io.NopCloser(strings.NewReader("abcdef"))
body, err := readRequestBody(req, 3)
remaining, readErr := io.ReadAll(req.Body)
assert.ErrorIs(t, err, errRequestBodyTooLarge)
assert.Nil(t, body)
assert.NoError(t, readErr)
assert.Equal(t, "ef", string(remaining))
}
func newSignedPayloadTestIAM(t *testing.T) *IdentityAccessManagement {
iam := &IdentityAccessManagement{
hashes: make(map[string]*sync.Pool),
hashCounters: make(map[string]*int32),
}
err := iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{
Name: "signer",
Credentials: []*iam_pb.Credential{{AccessKey: "access_key_1", SecretKey: "secret_key_1"}},
Actions: []string{"Read", "Write"},
}},
})
if err != nil {
t.Fatal(err)
}
return iam
}
func deleteObjectsBody(keys ...string) []byte {
var b strings.Builder
b.WriteString("<Delete>")
for _, k := range keys {
b.WriteString("<Object><Key>" + k + "</Key></Object>")
}
b.WriteString("</Delete>")
return []byte(b.String())
}
func TestSignedRequestBodyIsBoundToPayloadHash(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
approved := deleteObjectsBody("tables/a/own.txt")
swapped := deleteObjectsBody("tables/b/victim.txt")
newSwapped := func(body []byte) *http.Request {
req := mustNewSignedRequest(http.MethodPost, "http://127.0.0.1:9000/warehouse?delete", int64(len(approved)), bytes.NewReader(approved), t)
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
return req
}
t.Run("signed body is readable", func(t *testing.T) {
req := mustNewSignedRequest(http.MethodPost, "http://127.0.0.1:9000/warehouse?delete", int64(len(approved)), bytes.NewReader(approved), t)
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
assert.Equal(t, approved, body)
})
t.Run("replaced body is refused", func(t *testing.T) {
req := newSwapped(swapped)
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
body, err := io.ReadAll(req.Body)
assert.ErrorIs(t, err, errContentSha256Mismatch)
assert.Empty(t, body)
})
t.Run("decoder that stops early sees no replaced bytes", func(t *testing.T) {
padded := append(append([]byte{}, swapped...), bytes.Repeat([]byte(" "), 64<<10)...)
req := newSwapped(padded)
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
var parsed DeleteObjectsRequest
err := xml.NewDecoder(req.Body).Decode(&parsed)
assert.ErrorIs(t, err, errContentSha256Mismatch)
assert.Empty(t, parsed.Objects)
})
t.Run("emptied body is refused", func(t *testing.T) {
req := newSwapped(nil)
req.Body = http.NoBody
_, errCode := iam.reqSignatureV4Verify(req)
assert.Equal(t, s3err.ErrContentSHA256Mismatch, errCode)
})
t.Run("oversized body is refused before it is read", func(t *testing.T) {
req := newSwapped(swapped)
req.ContentLength = iamRequestBodyLimit + 1
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
_, err := io.ReadAll(req.Body)
assert.ErrorIs(t, err, errRequestBodyTooLarge)
})
}
func TestGetRequestDataReaderStreamsSignedUploads(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
s3a := &S3ApiServer{iam: iam}
signed := []byte("object data")
req := mustNewSignedRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key", int64(len(signed)), bytes.NewReader(signed), t)
raw := io.NopCloser(bytes.NewReader([]byte("other data!")))
req.Body = raw
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
dataReader, errCode := getRequestDataReader(s3a, req)
require.Equal(t, s3err.ErrNone, errCode)
assert.Equal(t, raw, req.Body)
_, err := io.ReadAll(dataReader)
assert.ErrorContains(t, err, s3err.ErrMsgContentSha256Mismatch)
}
func TestDeleteObjectsRefusesReplacedBody(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
s3a := &S3ApiServer{iam: iam}
approved := deleteObjectsBody("tables/a/own.txt")
swapped := deleteObjectsBody("tables/b/victim.txt")
req := mustNewSignedRequest(http.MethodPost, "http://127.0.0.1:9000/warehouse?delete", int64(len(approved)), bytes.NewReader(approved), t)
req.Body = io.NopCloser(bytes.NewReader(swapped))
req.ContentLength = int64(len(swapped))
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
rec := httptest.NewRecorder()
s3a.DeleteMultipleObjectsHandler(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "XAmzContentSHA256Mismatch")
}
func TestSignedRequestBodyIsBoundToContentMd5(t *testing.T) {
approved := deleteObjectsBody("tables/a/own.txt")
swapped := deleteObjectsBody("tables/b/victim.txt")
const url = "http://127.0.0.1:9000/warehouse?delete"
t.Run("v4 unsigned payload", func(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
req := mustNewRequest(http.MethodPost, url, int64(len(approved)), bytes.NewReader(approved), t)
req.Header.Set("X-Amz-Content-Sha256", unsignedPayload)
require.NoError(t, signRequestV4(req, "access_key_1", "secret_key_1"))
req.Body = io.NopCloser(bytes.NewReader(swapped))
req.ContentLength = int64(len(swapped))
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
_, err := io.ReadAll(req.Body)
assert.ErrorIs(t, err, errContentMd5Mismatch)
assert.Equal(t, s3err.ErrBadDigest, requestBodyErrorCode(err, s3err.ErrInternalError))
})
t.Run("v2", func(t *testing.T) {
iam := setupTestIAMForV2Auth()
cred := iam.identities[0].Credentials[0]
newV2Request := func(body []byte) *http.Request {
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
req.Header.Set("Content-Md5", getMD5HashBase64(approved))
req.Header.Set("Authorization", signatureV2(cred, req.Method, req.URL.Path, req.URL.Query().Encode(), req.Header))
return req
}
req := newV2Request(approved)
_, errCode := iam.isReqAuthenticatedV2(req)
require.Equal(t, s3err.ErrNone, errCode)
body, err := io.ReadAll(req.Body)
require.NoError(t, err)
assert.Equal(t, approved, body)
req = newV2Request(swapped)
_, errCode = iam.isReqAuthenticatedV2(req)
require.Equal(t, s3err.ErrNone, errCode)
_, err = io.ReadAll(req.Body)
assert.ErrorIs(t, err, errContentMd5Mismatch)
})
t.Run("malformed digest", func(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
req := mustNewRequest(http.MethodPost, url, int64(len(approved)), bytes.NewReader(approved), t)
req.Header.Set("Content-Md5", "bm90LWFuLW1kNQ==")
require.NoError(t, signRequestV4(req, "access_key_1", "secret_key_1"))
_, errCode := iam.reqSignatureV4Verify(req)
assert.Equal(t, s3err.ErrInvalidDigest, errCode)
})
}
func TestGetRequestDataReaderVerifiesPresignedPayloadHash(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
s3a := &S3ApiServer{iam: iam}
signed := []byte("object data")
sum := sha256.Sum256(signed)
req := mustNewRequest(http.MethodPut, "http://127.0.0.1:9000/bucket/key?X-Amz-Content-Sha256="+hex.EncodeToString(sum[:]), int64(len(signed)), bytes.NewReader(signed), t)
req.Header.Del("X-Amz-Content-Sha256")
req.Header.Del("Content-Md5")
require.NoError(t, preSignV4(iam, req, "access_key_1", "secret_key_1", 600))
req.Body = io.NopCloser(bytes.NewReader([]byte("other data!")))
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
dataReader, errCode := getRequestDataReader(s3a, req)
require.Equal(t, s3err.ErrNone, errCode)
_, err := io.ReadAll(dataReader)
assert.ErrorContains(t, err, s3err.ErrMsgContentSha256Mismatch)
}
func TestPrebufferedSignedBodyChecksContentMd5(t *testing.T) {
iam := newSignedPayloadTestIAM(t)
newIAMRequest := func(contentMd5 string) *http.Request {
body := []byte("Action=ListUsers&Version=2010-05-08")
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:9000/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Md5", contentMd5)
now := time.Now().UTC()
req.Header.Set("X-Amz-Date", now.Format(iso8601Format))
scope := now.Format(yyyymmdd) + "/us-east-1/iam/aws4_request"
signed := http.Header{
"content-md5": {contentMd5},
"content-type": {req.Header.Get("Content-Type")},
"host": {req.Host},
"x-amz-date": {req.Header.Get("X-Amz-Date")},
}
sum := sha256.Sum256(body)
canonical := getCanonicalRequest(signed, hex.EncodeToString(sum[:]), "", "/", http.MethodPost)
signature := getSignature(getSigningKey("secret_key_1", now.Format(yyyymmdd), "us-east-1", "iam"), getStringToSign(canonical, now, scope))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=access_key_1/"+scope+", SignedHeaders=content-md5;content-type;host;x-amz-date, Signature="+signature)
return req
}
req := newIAMRequest(getMD5HashBase64([]byte("Action=ListUsers&Version=2010-05-08")))
_, errCode := iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
_, err := io.ReadAll(req.Body)
assert.NoError(t, err)
req = newIAMRequest(getMD5HashBase64([]byte("other")))
_, errCode = iam.reqSignatureV4Verify(req)
require.Equal(t, s3err.ErrNone, errCode)
_, err = io.ReadAll(req.Body)
assert.ErrorIs(t, err, errContentMd5Mismatch)
}