mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-28 16:00:37 +01:00
* s3: keep multipart part encryption on the initiate-time decision putToFiler applied the *current* bucket default encryption when writing a staged part, so a PutBucketEncryption landing between two UploadPart calls produced an object whose parts disagree: some chunks ciphertext, some plaintext. Completion then marks the object by its majority and GetObject fails decrypting the rest. Parts staged under .uploads now skip the bucket-default lookup; the decision snapshotted into the upload entry at CreateMultipartUpload is replayed through request headers instead, which matches AWS (default encryption applies at initiation). * s3: mark multipart part writes via request context, not the staged path Inferring "is a part" from the .uploads file path let an ordinary PutObject named .uploads/x.part skip bucket default encryption entirely. The part decision now travels on the request context from the two callers that stage parts (UploadPart, UploadPartCopy); a client-chosen key cannot reach it.
221 lines
7.1 KiB
Go
221 lines
7.1 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
)
|
|
|
|
// Issue 11647: Bucket default encryption (SSE-S3) must be applied when
|
|
// -s3.encryptVolumeData (s3a.cipher) is enabled, matching explicit SSE headers.
|
|
func TestPutObjectAppliesBucketDefaultEncryptionWithVolumeCipher(t *testing.T) {
|
|
// Configure test key manager with a super key for SSE-S3 encryption.
|
|
km := GetSSES3KeyManager()
|
|
oldSuperKey := km.superKey
|
|
km.superKey = make([]byte, 32)
|
|
for i := range km.superKey {
|
|
km.superKey[i] = byte(i + 1)
|
|
}
|
|
t.Cleanup(func() {
|
|
km.superKey = oldSuperKey
|
|
})
|
|
|
|
testCases := []struct {
|
|
name string
|
|
enableCipher bool
|
|
}{
|
|
{
|
|
name: "volume encryption enabled (s3a.cipher=true)",
|
|
enableCipher: true,
|
|
},
|
|
{
|
|
name: "volume encryption disabled (s3a.cipher=false)",
|
|
enableCipher: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
volume := startFakeVolumeServer(t)
|
|
filerImpl := &ambiguousPutFiler{
|
|
volume: volume,
|
|
entries: map[string]*filer_pb.Entry{},
|
|
apply: true,
|
|
}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
|
s3a.cipher = tc.enableCipher
|
|
|
|
// Configure bucket "b" with default AES256 (SSE-S3) encryption.
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{
|
|
Name: "b",
|
|
Encryption: &s3_pb.EncryptionConfiguration{
|
|
SseAlgorithm: "AES256",
|
|
},
|
|
})
|
|
|
|
// PUT request without explicit SSE headers.
|
|
r := httptest.NewRequest(http.MethodPut, "/b/plain.txt", nil)
|
|
filePath := "/buckets/b/plain.txt"
|
|
etag, code, sseMeta := s3a.putToFiler(r, filePath, strings.NewReader("hello seaweedfs"), "b", "plain.txt", 1, 0, nil, false, "")
|
|
if code != s3err.ErrNone {
|
|
t.Fatalf("putToFiler returned error code %v, want %v", code, s3err.ErrNone)
|
|
}
|
|
if etag == "" {
|
|
t.Fatal("expected non-empty etag")
|
|
}
|
|
|
|
// Verify returned SSE response metadata.
|
|
if sseMeta.SSEType != s3_constants.SSETypeS3 {
|
|
t.Fatalf("expected SSE response metadata type %s, got %s", s3_constants.SSETypeS3, sseMeta.SSEType)
|
|
}
|
|
|
|
// Verify entry saved on the filer.
|
|
entry, ok := filerImpl.entries[filePath]
|
|
if !ok {
|
|
t.Fatalf("entry not found on filer at %s", filePath)
|
|
}
|
|
|
|
sseHeaderVal, hasSSE := entry.Extended[s3_constants.AmzServerSideEncryption]
|
|
if !hasSSE || !bytes.Equal(sseHeaderVal, []byte("AES256")) {
|
|
t.Fatalf("expected entry to have %s=AES256, got hasSSE=%v val=%s", s3_constants.AmzServerSideEncryption, hasSSE, string(sseHeaderVal))
|
|
}
|
|
|
|
if len(entry.Extended[s3_constants.SeaweedFSSSES3Key]) == 0 {
|
|
t.Fatal("expected entry to have stored SSE-S3 key metadata")
|
|
}
|
|
|
|
if len(entry.Chunks) == 0 {
|
|
t.Fatal("expected entry to have at least one chunk")
|
|
}
|
|
|
|
for i, chunk := range entry.Chunks {
|
|
if chunk.SseType != filer_pb.SSEType_SSE_S3 {
|
|
t.Errorf("chunk %d: expected SseType SSE_S3, got %v", i, chunk.SseType)
|
|
}
|
|
if len(chunk.SseMetadata) == 0 {
|
|
t.Errorf("chunk %d: expected non-empty SseMetadata", i)
|
|
}
|
|
if tc.enableCipher && len(chunk.CipherKey) == 0 {
|
|
t.Errorf("chunk %d: expected volume CipherKey when s3a.cipher is true", i)
|
|
}
|
|
if !tc.enableCipher && len(chunk.CipherKey) != 0 {
|
|
t.Errorf("chunk %d: expected empty volume CipherKey when s3a.cipher is false", i)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A part staged under .uploads must not pick up a bucket default encryption
|
|
// change: the upload's encryption is fixed at CreateMultipartUpload, so a part
|
|
// written after PutBucketEncryption would otherwise mix plaintext and
|
|
// ciphertext parts and the completed object would fail to decrypt.
|
|
func TestPutObjectPartSkipsBucketDefaultEncryption(t *testing.T) {
|
|
volume := startFakeVolumeServer(t)
|
|
filerImpl := &ambiguousPutFiler{
|
|
volume: volume,
|
|
entries: map[string]*filer_pb.Entry{},
|
|
apply: true,
|
|
}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{
|
|
Name: "b",
|
|
Encryption: &s3_pb.EncryptionConfiguration{
|
|
SseAlgorithm: "AES256",
|
|
},
|
|
})
|
|
|
|
filePath := "/buckets/b/.uploads/up1/0002_deadbeef.part"
|
|
r := withMultipartPart(httptest.NewRequest(http.MethodPut, "/b/o?partNumber=2&uploadId=up1", nil))
|
|
etag, code, sseMeta := s3a.putToFiler(r, filePath, strings.NewReader("part data"), "b", "", 2, 0, nil, false, "")
|
|
if code != s3err.ErrNone {
|
|
t.Fatalf("putToFiler = %v, want %v", code, s3err.ErrNone)
|
|
}
|
|
if etag == "" {
|
|
t.Fatal("expected non-empty etag")
|
|
}
|
|
if sseMeta.SSEType != "" {
|
|
t.Fatalf("part unexpectedly encrypted, SSEType=%s", sseMeta.SSEType)
|
|
}
|
|
|
|
entry, ok := filerImpl.entries[filePath]
|
|
if !ok {
|
|
t.Fatalf("entry not found on filer at %s", filePath)
|
|
}
|
|
if _, has := entry.Extended[s3_constants.AmzServerSideEncryption]; has {
|
|
t.Fatal("part entry carries SSE attributes from bucket default encryption")
|
|
}
|
|
for i, chunk := range entry.Chunks {
|
|
if chunk.SseType != filer_pb.SSEType_NONE {
|
|
t.Errorf("chunk %d: expected SseType NONE, got %v", i, chunk.SseType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An ordinary PutObject whose key happens to look like a staged part path
|
|
// (.uploads/...part) is still a top-level object write and must get the
|
|
// bucket's default encryption; only calls explicitly marked as part writes
|
|
// skip it.
|
|
func TestPutObjectUnderUploadsFolderKeepsBucketDefaultEncryption(t *testing.T) {
|
|
km := GetSSES3KeyManager()
|
|
oldSuperKey := km.superKey
|
|
km.superKey = make([]byte, 32)
|
|
for i := range km.superKey {
|
|
km.superKey[i] = byte(i + 1)
|
|
}
|
|
t.Cleanup(func() {
|
|
km.superKey = oldSuperKey
|
|
})
|
|
|
|
volume := startFakeVolumeServer(t)
|
|
filerImpl := &ambiguousPutFiler{
|
|
volume: volume,
|
|
entries: map[string]*filer_pb.Entry{},
|
|
apply: true,
|
|
}
|
|
s3a := newPutTestServer(t, startFakeFiler(t, filerImpl))
|
|
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
|
|
s3a.bucketConfigCache.Set("b", &BucketConfig{
|
|
Name: "b",
|
|
Encryption: &s3_pb.EncryptionConfiguration{
|
|
SseAlgorithm: "AES256",
|
|
},
|
|
})
|
|
|
|
filePath := "/buckets/b/.uploads/notes.part"
|
|
r := httptest.NewRequest(http.MethodPut, "/b/.uploads/notes.part", nil)
|
|
etag, code, sseMeta := s3a.putToFiler(r, filePath, strings.NewReader("ordinary object body"), "b", ".uploads/notes.part", 1, 0, nil, false, "")
|
|
if code != s3err.ErrNone {
|
|
t.Fatalf("putToFiler = %v, want %v", code, s3err.ErrNone)
|
|
}
|
|
if etag == "" {
|
|
t.Fatal("expected non-empty etag")
|
|
}
|
|
if sseMeta.SSEType != s3_constants.SSETypeS3 {
|
|
t.Fatalf("expected SSE response metadata type %s, got %s", s3_constants.SSETypeS3, sseMeta.SSEType)
|
|
}
|
|
|
|
entry, ok := filerImpl.entries[filePath]
|
|
if !ok {
|
|
t.Fatalf("entry not found on filer at %s", filePath)
|
|
}
|
|
if !bytes.Equal(entry.Extended[s3_constants.AmzServerSideEncryption], []byte("AES256")) {
|
|
t.Fatal("expected object entry to keep bucket default AES256 encryption")
|
|
}
|
|
for i, chunk := range entry.Chunks {
|
|
if chunk.SseType != filer_pb.SSEType_SSE_S3 {
|
|
t.Errorf("chunk %d: expected SseType SSE_S3, got %v", i, chunk.SseType)
|
|
}
|
|
}
|
|
}
|