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>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-12 10:17:09 -08:00
1 parent a3b68aa37c
commit 6388dfca9b
9 files changed
+436 -15

No files matched your search

+9 -2
View File
@@ -63,10 +63,17 @@ var resourceList = []string{
// Verify if request has AWS Signature Version '2'.
func (iam *IdentityAccessManagement) isReqAuthenticatedV2(r *http.Request) (*Identity, s3err.ErrorCode) {
var identity *Identity
var errCode s3err.ErrorCode
if isRequestSignatureV2(r) {
return iam.doesSignV2Match(r)
identity, errCode = iam.doesSignV2Match(r)
} else {
identity, errCode = iam.doesPresignV2SignatureMatch(r)
}
return iam.doesPresignV2SignatureMatch(r)
if errCode != s3err.ErrNone {
return identity, errCode
}
return identity, bindSignedPayload(r, unsignedPayload)
}
func (iam *IdentityAccessManagement) doesPolicySignatureV2Match(ctx context.Context, formValues http.Header) (*Identity, s3err.ErrorCode) {
+7 -9
View File
@@ -26,7 +26,6 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
"io"
"net"
"net/http"
"net/url"
@@ -47,15 +46,14 @@ import (
)
func (iam *IdentityAccessManagement) reqSignatureV4Verify(r *http.Request) (*Identity, s3err.ErrorCode) {
switch {
case isRequestSignatureV4(r):
identity, _, errCode := iam.doesSignatureMatch(r)
return identity, errCode
case isRequestPresignedSignatureV4(r):
identity, _, errCode := iam.doesPresignedSignatureMatch(r)
if !isRequestSignatureV4(r) && !isRequestPresignedSignatureV4(r) {
return nil, s3err.ErrAccessDenied
}
identity, _, _, authInfo, errCode := iam.verifyV4Signature(r, false)
if errCode != s3err.ErrNone {
return identity, errCode
}
return nil, s3err.ErrAccessDenied
return identity, bindSignedPayload(r, authInfo.HashedPayload)
}
// Constants specific to this file
@@ -80,7 +78,7 @@ func streamHashRequestBody(r *http.Request, sizeLimit int64) (string, error) {
return "", err
}
r.Body = io.NopCloser(bytes.NewReader(body))
(&signedPayloadBody{data: bytes.NewReader(body)}).wrap(&r.Body)
if len(body) == 0 {
return emptySHA256, nil
+148 -1
View File
@@ -1,12 +1,22 @@
package s3api
import (
"bytes"
"crypto/md5"
"crypto/sha256"
"errors"
"io"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
var errRequestBodyTooLarge = errors.New("request body too large")
var (
errRequestBodyTooLarge = errors.New("request body too large")
errContentSha256Mismatch = errors.New(s3err.ErrMsgContentSha256Mismatch)
errContentMd5Mismatch = errors.New("the Content-MD5 you specified did not match what was received")
)
func readRequestBody(r *http.Request, limit int64) ([]byte, error) {
if r.Body == nil {
@@ -21,3 +31,140 @@ func readRequestBody(r *http.Request, limit int64) ([]byte, error) {
}
return body, nil
}
// signedPayloadBody hands out a signed request body only after all of it has
// been read and matched against the payload hash the signature covered and
// any Content-MD5, so a handler that stops reading early never acts on
// unverified bytes.
type signedPayloadBody struct {
body io.ReadCloser
size int64
limit int64
sha256 []byte
md5 []byte
data *bytes.Reader
err error
}
func (b *signedPayloadBody) Read(p []byte) (int, error) {
if b.data == nil && b.err == nil {
b.data, b.err = b.load()
}
if b.err != nil {
return 0, b.err
}
return b.data.Read(p)
}
func (b *signedPayloadBody) load() (*bytes.Reader, error) {
if b.size > b.limit {
return nil, errRequestBodyTooLarge
}
data, err := io.ReadAll(io.LimitReader(b.body, b.limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > b.limit {
return nil, errRequestBodyTooLarge
}
if b.sha256 != nil {
if sum := sha256.Sum256(data); !bytes.Equal(sum[:], b.sha256) {
return nil, errContentSha256Mismatch
}
}
if b.md5 != nil {
if sum := md5.Sum(data); !bytes.Equal(sum[:], b.md5) {
return nil, errContentMd5Mismatch
}
}
return bytes.NewReader(data), nil
}
func (b *signedPayloadBody) Close() error {
return b.body.Close()
}
func (b *signedPayloadBody) wrap(body *io.ReadCloser) {
b.body = *body
*body = b
}
// unwrapSignedPayload puts back the raw body of a signedPayloadBody nothing has
// read yet, for readers that verify it as it streams, and returns the hash it
// was bound to.
func unwrapSignedPayload(body *io.ReadCloser) []byte {
if b, ok := (*body).(*signedPayloadBody); ok && b.data == nil && b.err == nil {
*body = b.body
return b.sha256
}
return nil
}
// bindSignedPayload ties the request body to hashedPayload, the payload hash a
// V4 signature covered (UNSIGNED-PAYLOAD for V2), and to any Content-MD5.
// Uploads read through getRequestDataReader, which verifies the body as it
// streams instead.
func bindSignedPayload(r *http.Request, hashedPayload string) s3err.ErrorCode {
if bound, ok := r.Body.(*signedPayloadBody); ok {
if bound.sha256 != nil || bound.md5 != nil {
return s3err.ErrNone
}
// streamHashRequestBody buffered it and signed its hash; only
// Content-MD5 is left to check.
hashedPayload = unsignedPayload
}
switch hashedPayload {
case streamingContentSHA256, streamingContentSHA256Trailer, streamingUnsignedPayload:
return s3err.ErrNone
}
expected, valid := decodeContentSha256(hashedPayload)
hasBody := r.Body != nil && r.Body != http.NoBody && r.ContentLength != 0
if !valid {
if hasBody {
return s3err.ErrInvalidArgument
}
return s3err.ErrNone
}
var contentMd5 []byte
if _, present := r.Header["Content-Md5"]; present {
decoded, err := validateContentMd5(r.Header)
if err != nil || len(decoded) != md5.Size {
return s3err.ErrInvalidDigest
}
contentMd5 = decoded
}
if expected == nil && contentMd5 == nil {
return s3err.ErrNone
}
if !hasBody {
if emptySum := sha256.Sum256(nil); expected != nil && !bytes.Equal(expected, emptySum[:]) {
return s3err.ErrContentSHA256Mismatch
}
if emptySum := md5.Sum(nil); contentMd5 != nil && !bytes.Equal(contentMd5, emptySum[:]) {
return s3err.ErrBadDigest
}
return s3err.ErrNone
}
signed := &signedPayloadBody{
size: r.ContentLength,
limit: s3_constants.GetRequestBodyLimitFromContext(r, iamRequestBodyLimit),
sha256: expected,
md5: contentMd5,
}
signed.wrap(&r.Body)
return s3err.ErrNone
}
// requestBodyErrorCode names the check a failed body read tripped, or returns
// fallback for any other read error.
func requestBodyErrorCode(err error, fallback s3err.ErrorCode) s3err.ErrorCode {
switch {
case errors.Is(err, errContentSha256Mismatch):
return s3err.ErrContentSHA256Mismatch
case errors.Is(err, errContentMd5Mismatch):
return s3err.ErrBadDigest
case errors.Is(err, errRequestBodyTooLarge):
return s3err.ErrEntityTooLarge
}
return fallback
}
+241
View File
@@ -1,13 +1,22 @@
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) {
@@ -22,3 +31,235 @@ func TestReadRequestBodyLeavesOversizedBodyDrainable(t *testing.T) {
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)
}
+1 -1
View File
@@ -440,7 +440,7 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h
deleteXMLBytes, err := io.ReadAll(r.Body)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
s3err.WriteErrorResponse(w, r, requestBodyErrorCode(err, s3err.ErrInternalError))
return
}
@@ -164,7 +164,7 @@ func (s3a *S3ApiServer) CompleteMultipartUploadHandler(w http.ResponseWriter, r
parts := &CompleteMultipartUpload{}
if err := xmlDecoder(r.Body, parts, r.ContentLength); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
s3err.WriteErrorResponse(w, r, requestBodyErrorCode(err, s3err.ErrMalformedXML))
return
}
+4
View File
@@ -231,6 +231,10 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
// Compute MD5 for ETag (md5.Sum of nil/empty = MD5 of empty content)
dirMd5 := md5.Sum(dirContent)
if expectedMd5, _ := validateContentMd5(r.Header); len(expectedMd5) > 0 && !bytes.Equal(dirMd5[:], expectedMd5) {
s3err.WriteErrorResponse(w, r, s3err.ErrBadDigest)
return
}
dirEtag := fmt.Sprintf("%x", dirMd5)
// SSE applies to marker content too — it is stored in entry.Content
+8 -1
View File
@@ -19,6 +19,7 @@ import (
// newChunkedReader.
func getRequestDataReader(s3a *S3ApiServer, r *http.Request) (io.ReadCloser, s3err.ErrorCode) {
var s3ErrCode s3err.ErrorCode
signedSha256 := unwrapSignedPayload(&r.Body)
dataReader := r.Body
rAuthType := getRequestAuthType(r)
if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingUnsigned {
@@ -32,6 +33,9 @@ func getRequestDataReader(s3a *S3ApiServer, r *http.Request) (io.ReadCloser, s3e
if !valid {
return nil, s3err.ErrInvalidArgument
}
if signedSha256 != nil {
expected = signedSha256
}
if expected != nil && dataReader != nil {
if r.ContentLength == 0 {
// Handlers may never read an empty body, so check it now.
@@ -51,7 +55,10 @@ func getRequestDataReader(s3a *S3ApiServer, r *http.Request) (io.ReadCloser, s3e
// streams. Sentinel values (streaming, unsigned) carry no hash and are skipped;
// anything that decodes to neither returns valid=false.
func expectedContentSha256(r *http.Request) (expected []byte, valid bool) {
v := r.Header.Get("X-Amz-Content-Sha256")
return decodeContentSha256(r.Header.Get("X-Amz-Content-Sha256"))
}
func decodeContentSha256(v string) (expected []byte, valid bool) {
if v == "" || v == unsignedPayload || v == streamingContentSHA256 ||
v == streamingContentSHA256Trailer || v == streamingUnsignedPayload {
return nil, true
@@ -230,6 +230,23 @@ func TestPutObjectDirectoryMarkerStoresHeaders(t *testing.T) {
require.ElementsMatch(t, []Tag{{Key: "k1", Value: "v1"}, {Key: "k2", Value: "v2"}}, tagging.TagSet.Tag)
}
func TestPutObjectDirectoryMarkerChecksContentMd5(t *testing.T) {
s3a, filer, _ := newHeaderTestServer(t, "")
req, probe := newHeaderTestRequest("/b/dir/", "dir/", "marker", "Content-Md5", getMD5HashBase64([]byte("other")))
s3a.PutObjectHandler(probe, req)
require.Equal(t, http.StatusBadRequest, probe.Code, probe.Body.String())
require.Contains(t, probe.Body.String(), "BadDigest")
filer.mu.Lock()
require.Nil(t, filer.entries["/buckets/b/dir"], "directory marker created")
filer.mu.Unlock()
req, probe = newHeaderTestRequest("/b/dir/", "dir/", "marker", "Content-Md5", getMD5HashBase64([]byte("marker")))
s3a.PutObjectHandler(probe, req)
require.Equal(t, http.StatusOK, probe.Code, probe.Body.String())
}
// A valid storage class still stores, and the content type is still sniffed.
func TestPutObjectValidStorageClassSniffsContentType(t *testing.T) {
s3a, filer, volume := newHeaderTestServer(t, "")