mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-31 01:10:41 +01:00
s3api: verify x-amz-content-sha256 against the streamed body
A PUT carrying a hex or base64 payload hash now streams through a verifier that reports a mismatch once the stream is exhausted, instead of storing an object that does not match its declared hash. The error is deferred so intermediate reads that drop (n>0, err) results cannot silently swallow it. Sentinel values (unsigned/streaming payloads) remain exempt and malformed values fail fast. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
06fde6e2f6
commit
eb744e71bd
4 files changed
+208
-2
No files matched your search
@@ -202,7 +202,11 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
|
||||
dirContent, readErr = io.ReadAll(dataReader)
|
||||
if readErr != nil {
|
||||
glog.Errorf("PutObjectHandler: failed to read directory marker content %s/%s: %v", bucket, object, readErr)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
if strings.Contains(readErr.Error(), s3err.ErrMsgContentSha256Mismatch) {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrContentSHA256Mismatch)
|
||||
} else {
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1536,6 +1540,8 @@ func mapChunkedUploadErrorToS3Error(reqCtx context.Context, err error) s3err.Err
|
||||
switch {
|
||||
case errors.Is(err, weed_server.ErrReadOnly):
|
||||
return s3err.ErrAccessDenied
|
||||
case strings.Contains(err.Error(), s3err.ErrMsgContentSha256Mismatch):
|
||||
return s3err.ErrContentSHA256Mismatch
|
||||
case strings.Contains(err.Error(), s3err.ErrMsgPayloadChecksumMismatch):
|
||||
return s3err.ErrInvalidDigest
|
||||
case errors.Is(err, operation.ErrTruncatedBody):
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
@@ -30,6 +36,76 @@ func getRequestDataReader(s3a *S3ApiServer, r *http.Request) (io.ReadCloser, s3e
|
||||
dataReader, s3ErrCode = s3a.iam.newChunkedReader(r)
|
||||
}
|
||||
}
|
||||
if s3ErrCode != s3err.ErrNone {
|
||||
return nil, s3ErrCode
|
||||
}
|
||||
|
||||
return dataReader, s3ErrCode
|
||||
expected, valid := expectedContentSha256(r)
|
||||
if !valid {
|
||||
return nil, s3err.ErrContentSHA256Mismatch
|
||||
}
|
||||
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) {
|
||||
v := r.Header.Get("X-Amz-Content-Sha256")
|
||||
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()
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -183,3 +189,120 @@ func TestAuthTypeDetection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func TestExpectedContentSha256(t *testing.T) {
|
||||
good := sha256Hex(t, []byte("payload"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
wantValid bool
|
||||
wantNil bool
|
||||
}{
|
||||
{"absent", "", true, true},
|
||||
{"unsigned", unsignedPayload, true, true},
|
||||
{"streaming signed", streamingContentSHA256, true, true},
|
||||
{"streaming trailer", streamingContentSHA256Trailer, true, true},
|
||||
{"streaming unsigned", streamingUnsignedPayload, true, true},
|
||||
{"hex", good, true, false},
|
||||
{"not hex", "nothex", false, true},
|
||||
{"short hex", "deadbeef", false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("PUT", "/b/o", nil)
|
||||
if tt.header != "" {
|
||||
r.Header.Set("X-Amz-Content-Sha256", tt.header)
|
||||
}
|
||||
got, valid := expectedContentSha256(r)
|
||||
if valid != tt.wantValid {
|
||||
t.Fatalf("valid=%v want %v", valid, tt.wantValid)
|
||||
}
|
||||
if (got == nil) != tt.wantNil {
|
||||
t.Fatalf("expected nil=%v, got %x", tt.wantNil, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("base64 decodes to sha256", func(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte("payload"))
|
||||
r := httptest.NewRequest("PUT", "/b/o", nil)
|
||||
r.Header.Set("X-Amz-Content-Sha256", base64.StdEncoding.EncodeToString(sum[:]))
|
||||
got, valid := expectedContentSha256(r)
|
||||
if !valid || !bytes.Equal(got, sum[:]) {
|
||||
t.Fatalf("valid=%v got=%x", valid, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newVerifier(body string, expectedHex string) *contentSha256Verifier {
|
||||
expected, _ := hex.DecodeString(expectedHex)
|
||||
return &contentSha256Verifier{
|
||||
reader: io.NopCloser(strings.NewReader(body)),
|
||||
hasher: sha256.New(),
|
||||
expected: expected,
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentSha256VerifierMatch(t *testing.T) {
|
||||
v := newVerifier("hello", sha256Hex(t, []byte("hello")))
|
||||
got, err := io.ReadAll(v)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentSha256VerifierMismatch(t *testing.T) {
|
||||
v := newVerifier("hello", sha256Hex(t, []byte("other")))
|
||||
_, err := io.ReadAll(v)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected mismatch error, got %v", err)
|
||||
}
|
||||
if _, err := v.Read(make([]byte, 8)); err == nil {
|
||||
t.Fatal("expected error to persist")
|
||||
}
|
||||
}
|
||||
|
||||
// mimeDetect performs a single Read and ignores its error; the verifier must
|
||||
// defer the mismatch so a dropped (n>0, err) result still surfaces later.
|
||||
func TestContentSha256VerifierSurvivesDroppedError(t *testing.T) {
|
||||
v := newVerifier("hello", sha256Hex(t, []byte("other")))
|
||||
buf := make([]byte, 512)
|
||||
n, _ := v.Read(buf)
|
||||
rest := io.MultiReader(bytes.NewReader(buf[:n]), v)
|
||||
_, err := io.ReadAll(rest)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
||||
t.Fatalf("expected deferred mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-length body never reaches the verifier, so the declared hash is
|
||||
// compared against the empty-payload digest up front.
|
||||
func TestGetRequestDataReaderEmptyBodyHash(t *testing.T) {
|
||||
s3a := &S3ApiServer{
|
||||
iam: NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, string(credential.StoreTypeMemory)),
|
||||
}
|
||||
s3a.iam.isAuthEnabled = false
|
||||
|
||||
mismatch := httptest.NewRequest("PUT", "/b/dir/", http.NoBody)
|
||||
mismatch.Header.Set("X-Amz-Content-Sha256", sha256Hex(t, []byte("other")))
|
||||
if _, code := getRequestDataReader(s3a, mismatch); code != s3err.ErrContentSHA256Mismatch {
|
||||
t.Fatalf("empty body with wrong hash: code=%v", code)
|
||||
}
|
||||
|
||||
emptySum := sha256.Sum256(nil)
|
||||
match := httptest.NewRequest("PUT", "/b/dir/", http.NoBody)
|
||||
match.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(emptySum[:]))
|
||||
if _, code := getRequestDataReader(s3a, match); code != s3err.ErrNone {
|
||||
t.Fatalf("empty body with empty hash: code=%v", code)
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,7 @@ const (
|
||||
|
||||
// Error message constants for checksum validation
|
||||
const (
|
||||
ErrMsgContentSha256Mismatch = "the provided 'x-amz-content-sha256' header does not match what was computed"
|
||||
ErrMsgPayloadChecksumMismatch = "payload checksum does not match"
|
||||
ErrMsgChunkSignatureMismatch = "chunk signature does not match"
|
||||
ErrMsgChecksumAlgorithmMismatch = "checksum algorithm mismatch"
|
||||
|
||||
Reference in new issue
Block a user