mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-12 23:09:33 +01:00
* 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>
171 lines
4.6 KiB
Go
171 lines
4.6 KiB
Go
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")
|
|
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 {
|
|
return nil, nil
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return nil, errRequestBodyTooLarge
|
|
}
|
|
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
|
|
}
|