Files
seaweedfs/weed/s3api/s3api_put_object_helper.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

107 lines
3.1 KiB
Go

package s3api
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"hash"
"io"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// getRequestDataReader returns the appropriate reader for the request body.
// Chunked streaming uploads are decoded regardless of whether IAM is enabled;
// with no credentials to verify against, signature checking is skipped inside
// 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 {
dataReader, s3ErrCode = s3a.iam.newChunkedReader(r)
}
if s3ErrCode != s3err.ErrNone {
return nil, s3ErrCode
}
expected, valid := expectedContentSha256(r)
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.
emptySum := sha256.Sum256(nil)
if !bytes.Equal(expected, emptySum[:]) {
return nil, s3err.ErrContentSHA256Mismatch
}
return dataReader, s3err.ErrNone
}
dataReader = &contentSha256Verifier{reader: dataReader, hasher: sha256.New(), expected: expected}
}
return dataReader, s3err.ErrNone
}
// expectedContentSha256 decodes the x-amz-content-sha256 header when it declares
// a payload hash — hex or base64 per SigV4 — so the body can be checked as it
// 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) {
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
}
if decoded, err := hex.DecodeString(v); err == nil && len(decoded) == sha256.Size {
return decoded, true
}
if decoded, err := base64.StdEncoding.DecodeString(v); err == nil && len(decoded) == sha256.Size {
return decoded, true
}
return nil, false
}
// contentSha256Verifier checks the decoded x-amz-content-sha256 once the stream
// is exhausted; handlers that never consume the body are unaffected.
type contentSha256Verifier struct {
reader io.ReadCloser
hasher hash.Hash
expected []byte
done bool
pending error
}
func (v *contentSha256Verifier) Read(p []byte) (int, error) {
if v.pending != nil {
return 0, v.pending
}
n, err := v.reader.Read(p)
v.hasher.Write(p[:n])
if err == io.EOF && !v.done {
v.done = true
if !bytes.Equal(v.hasher.Sum(nil), v.expected) {
v.pending = errors.New(s3err.ErrMsgContentSha256Mismatch)
err = nil
}
}
if n == 0 && v.pending != nil {
return 0, v.pending
}
return n, err
}
func (v *contentSha256Verifier) Close() error {
return v.reader.Close()
}