mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-19 13:46:58 +00:00
fix(s3api): preserve requested AES256 copy encryption (#10049)
* fix(s3api): preserve requested AES256 copy encryption Problem CopyObject metadata processing ignored an explicit x-amz-server-side-encryption: AES256 request header. A destination copy could lose the requested SSE-S3 metadata even though KMS requests were handled. Root cause processMetadataBytes only wrote the destination SSE header when the requested algorithm was aws:kms. Any other explicit SSE algorithm fell through to the source-preservation branch. Fix Write the requested SSE algorithm whenever x-amz-server-side-encryption is present, and keep KMS-specific metadata handling limited to aws:kms. Co-authored-by: Codex <noreply@openai.com> * fix(s3api): reject unsupported copy encryption algorithms A mistyped or unsupported x-amz-server-side-encryption value on a copy request slipped past validation and got persisted as the destination's algorithm header, advertising encryption that was never applied. Reject anything other than AES256 or aws:kms up front. * fix(s3api): write SSE key metadata for empty encrypted copies A zero-byte source copied with an explicit SSE request took the no-content branch and never ran the encryption path, leaving the object with a bare algorithm header but no key. HEAD then advertised SSE while the encryption-state machine saw the header as orphaned. Run the inline encryption path when the destination requests encryption so the key metadata is written too. * s3api: use SSEAlgorithmKMS constant in copy metadata handling * test(s3api): cover source SSE preservation on copy * test(iam): allow the local client's real source IP in SourceIp tests The aws:SourceIp allow policies hardcoded the loopback CIDRs, but a CI runner reaching the server over localhost can be observed with one of the host's RFC1918 addresses (the S3 endpoint is advertised on a 10.x interface), so the positive-condition PutObject was denied and the allow assertion flaked while the deny path passed trivially. Broaden the allow list to loopback plus private ranges via a shared helper, and log the denial on each failed attempt so any residual failure is diagnosable. --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
@@ -37,10 +37,43 @@ func isAccessDenied(err error) bool {
|
||||
return ok && awsErr.Code() == "AccessDenied"
|
||||
}
|
||||
|
||||
// localSourceAllowCIDRs lists every address a loopback-targeted test client may
|
||||
// present as aws:SourceIp. The SDK reaches the server over localhost, but
|
||||
// depending on resolver order and host routing the source the server observes
|
||||
// can be IPv4 loopback, IPv6 loopback, or one of the host's RFC1918 addresses
|
||||
// (CI runners advertise the S3 endpoint on a 10.x interface). An allow policy
|
||||
// meant to match "this local client" must cover all of them or the
|
||||
// positive-condition assertion flakes.
|
||||
var localSourceAllowCIDRs = []string{
|
||||
"127.0.0.0/8",
|
||||
"::1/128",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
}
|
||||
|
||||
// sourceIpAllowPolicy builds an Allow policy for s3:* on bucketName gated by an
|
||||
// aws:SourceIp IpAddress condition over the given CIDRs.
|
||||
func sourceIpAllowPolicy(bucketName string, cidrs ...string) string {
|
||||
quoted := make([]string, len(cidrs))
|
||||
for i, c := range cidrs {
|
||||
quoted[i] = `"` + c + `"`
|
||||
}
|
||||
return `{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow",
|
||||
"Action":"s3:*",
|
||||
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
|
||||
"Condition":{"IpAddress":{"aws:SourceIp":[` + strings.Join(quoted, ",") + `]}}
|
||||
}]
|
||||
}`
|
||||
}
|
||||
|
||||
// TestIAMUserInlinePolicySourceIpCondition verifies that an aws:SourceIp condition
|
||||
// on a user inline policy is honored. Tests run from localhost (127.0.0.1), so a
|
||||
// policy that only allows access from a non-loopback CIDR must deny the request,
|
||||
// and a policy that allows access from 127.0.0.0/8 must allow it.
|
||||
// on a user inline policy is honored. Tests run against a local server, so a
|
||||
// policy that only allows a non-local CIDR must deny the request, and a policy
|
||||
// that allows the local client's address (see localSourceAllowCIDRs) must allow it.
|
||||
func TestIAMUserInlinePolicySourceIpCondition(t *testing.T) {
|
||||
framework := NewS3IAMTestFramework(t)
|
||||
defer framework.Cleanup()
|
||||
@@ -87,30 +120,13 @@ func TestIAMUserInlinePolicySourceIpCondition(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
policyDoc := func(cidrs ...string) string {
|
||||
quoted := make([]string, len(cidrs))
|
||||
for i, c := range cidrs {
|
||||
quoted[i] = `"` + c + `"`
|
||||
}
|
||||
return `{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow",
|
||||
"Action":"s3:*",
|
||||
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
|
||||
"Condition":{"IpAddress":{"aws:SourceIp":[` + strings.Join(quoted, ",") + `]}}
|
||||
}]
|
||||
}`
|
||||
}
|
||||
|
||||
t.Run("denies_when_source_ip_does_not_match", func(t *testing.T) {
|
||||
// SourceIp 198.51.100.0/24 is RFC5737 TEST-NET-2; the test client is on
|
||||
// loopback (127.0.0.1 or ::1 depending on resolver), so the condition
|
||||
// must fail and the action must be denied.
|
||||
// the local host, so the condition must fail and the action be denied.
|
||||
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc("198.51.100.0/24")),
|
||||
PolicyDocument: aws.String(sourceIpAllowPolicy(bucketName, "198.51.100.0/24")),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -127,25 +143,25 @@ func TestIAMUserInlinePolicySourceIpCondition(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("allows_when_source_ip_matches", func(t *testing.T) {
|
||||
// Cover both IPv4 and IPv6 loopback: on CI runners `localhost` may
|
||||
// resolve to ::1 first, in which case a 127.0.0.0/8-only allow would
|
||||
// silently never match and the test would hang.
|
||||
_, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{
|
||||
UserName: aws.String(userName),
|
||||
PolicyName: aws.String(policyName),
|
||||
PolicyDocument: aws.String(policyDoc("127.0.0.0/8", "::1/128")),
|
||||
PolicyDocument: aws.String(sourceIpAllowPolicy(bucketName, localSourceAllowCIDRs...)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := userS3.PutObject(&s3.PutObjectInput{
|
||||
_, putErr := userS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("allowed.txt"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("ok")),
|
||||
})
|
||||
return err == nil
|
||||
if putErr != nil {
|
||||
t.Logf("allow attempt denied (source IP not in %v?): %v", localSourceAllowCIDRs, putErr)
|
||||
}
|
||||
return putErr == nil
|
||||
}, 10*time.Second, 500*time.Millisecond,
|
||||
"PutObject must succeed when aws:SourceIp condition matches the loopback range")
|
||||
"PutObject must succeed when aws:SourceIp condition matches the local client address")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,27 +231,8 @@ func TestIAMGroupInlinePolicyEnforcement(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Cover both IPv4 and IPv6 loopback in the allow CIDR list: on CI runners
|
||||
// `localhost` may resolve to ::1 first, in which case a 127.0.0.0/8-only
|
||||
// allow would silently never match and the test would hang.
|
||||
allowDoc := `{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow",
|
||||
"Action":"s3:*",
|
||||
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
|
||||
"Condition":{"IpAddress":{"aws:SourceIp":["127.0.0.0/8","::1/128"]}}
|
||||
}]
|
||||
}`
|
||||
denyDoc := `{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Allow",
|
||||
"Action":"s3:*",
|
||||
"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"],
|
||||
"Condition":{"IpAddress":{"aws:SourceIp":"198.51.100.0/24"}}
|
||||
}]
|
||||
}`
|
||||
allowDoc := sourceIpAllowPolicy(bucketName, localSourceAllowCIDRs...)
|
||||
denyDoc := sourceIpAllowPolicy(bucketName, "198.51.100.0/24")
|
||||
|
||||
t.Run("crud_round_trip", func(t *testing.T) {
|
||||
_, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{
|
||||
@@ -277,12 +274,15 @@ func TestIAMGroupInlinePolicyEnforcement(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := userS3.PutObject(&s3.PutObjectInput{
|
||||
_, putErr := userS3.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String("group-allowed.txt"),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader("ok")),
|
||||
})
|
||||
return err == nil
|
||||
if putErr != nil {
|
||||
t.Logf("allow attempt denied (source IP not in %v?): %v", localSourceAllowCIDRs, putErr)
|
||||
}
|
||||
return putErr == nil
|
||||
}, 10*time.Second, 500*time.Millisecond,
|
||||
"group member must be allowed when the group policy condition matches")
|
||||
})
|
||||
|
||||
@@ -115,9 +115,19 @@ func validateSSEKMSCopyRequirements(srcMetadata map[string][]byte, headers http.
|
||||
|
||||
// validateEncryptionCompatibility validates that encryption methods are not conflicting
|
||||
func validateEncryptionCompatibility(headers http.Header) error {
|
||||
sseAlgorithm := headers.Get(s3_constants.AmzServerSideEncryption)
|
||||
hasSSEC := hasSSECHeaders(headers)
|
||||
hasSSEKMS := headers.Get(s3_constants.AmzServerSideEncryption) == "aws:kms"
|
||||
hasSSES3 := headers.Get(s3_constants.AmzServerSideEncryption) == "AES256"
|
||||
hasSSEKMS := sseAlgorithm == s3_constants.SSEAlgorithmKMS
|
||||
hasSSES3 := sseAlgorithm == s3_constants.SSEAlgorithmAES256
|
||||
|
||||
// Reject unsupported algorithms so they are never persisted as a bogus
|
||||
// destination header advertising encryption that was never applied.
|
||||
if sseAlgorithm != "" && !hasSSEKMS && !hasSSES3 {
|
||||
return &CopyValidationError{
|
||||
Code: s3err.ErrInvalidEncryptionAlgorithm,
|
||||
Message: fmt.Sprintf("Unsupported server-side encryption algorithm: %s", sseAlgorithm),
|
||||
}
|
||||
}
|
||||
|
||||
// Count how many encryption methods are specified
|
||||
encryptionCount := 0
|
||||
|
||||
@@ -350,8 +350,10 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request
|
||||
if entry.Attributes.FileSize == 0 || len(entry.GetChunks()) == 0 {
|
||||
dstEntry.Chunks = nil
|
||||
|
||||
// Handle inline encrypted content - fixes GitHub #7562
|
||||
if len(entry.Content) > 0 {
|
||||
// Handle inline encrypted content - fixes GitHub #7562.
|
||||
// Also run when the destination requests encryption with no content so
|
||||
// empty objects get real key metadata, not just a bare algorithm header.
|
||||
if len(entry.Content) > 0 || dstWantsSSEC || dstWantsSSEKMS || dstWantsSSES3 {
|
||||
inlineContent, inlineMetadata, inlineErr := s3a.processInlineContentForCopy(
|
||||
entry, r, dstBucket, dstObject,
|
||||
srcHasSSEC, srcHasSSEKMS, srcHasSSES3,
|
||||
@@ -1029,26 +1031,28 @@ func processMetadataBytes(reqHeader http.Header, existing map[string][]byte, rep
|
||||
metadata[s3_constants.AmzStorageClass] = []byte(sc)
|
||||
}
|
||||
|
||||
// Handle SSE-KMS headers - these are always processed from request headers if present
|
||||
if sseAlgorithm := reqHeader.Get(s3_constants.AmzServerSideEncryption); sseAlgorithm == "aws:kms" {
|
||||
// Handle destination SSE headers from the request when present.
|
||||
if sseAlgorithm := reqHeader.Get(s3_constants.AmzServerSideEncryption); sseAlgorithm != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryption] = []byte(sseAlgorithm)
|
||||
|
||||
// KMS Key ID (optional - can use default key)
|
||||
if kmsKeyID := reqHeader.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId); kmsKeyID != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryptionAwsKmsKeyId] = []byte(kmsKeyID)
|
||||
}
|
||||
if sseAlgorithm == s3_constants.SSEAlgorithmKMS {
|
||||
// KMS Key ID (optional - can use default key)
|
||||
if kmsKeyID := reqHeader.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId); kmsKeyID != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryptionAwsKmsKeyId] = []byte(kmsKeyID)
|
||||
}
|
||||
|
||||
// Encryption Context (optional)
|
||||
if encryptionContext := reqHeader.Get(s3_constants.AmzServerSideEncryptionContext); encryptionContext != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryptionContext] = []byte(encryptionContext)
|
||||
}
|
||||
// Encryption Context (optional)
|
||||
if encryptionContext := reqHeader.Get(s3_constants.AmzServerSideEncryptionContext); encryptionContext != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryptionContext] = []byte(encryptionContext)
|
||||
}
|
||||
|
||||
// Bucket Key Enabled (optional)
|
||||
if bucketKeyEnabled := reqHeader.Get(s3_constants.AmzServerSideEncryptionBucketKeyEnabled); bucketKeyEnabled != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryptionBucketKeyEnabled] = []byte(bucketKeyEnabled)
|
||||
// Bucket Key Enabled (optional)
|
||||
if bucketKeyEnabled := reqHeader.Get(s3_constants.AmzServerSideEncryptionBucketKeyEnabled); bucketKeyEnabled != "" {
|
||||
metadata[s3_constants.AmzServerSideEncryptionBucketKeyEnabled] = []byte(bucketKeyEnabled)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If not explicitly setting SSE-KMS, preserve existing SSE headers from source
|
||||
// If not explicitly setting SSE, preserve existing SSE headers from source
|
||||
for _, sseHeader := range []string{
|
||||
s3_constants.AmzServerSideEncryption,
|
||||
s3_constants.AmzServerSideEncryptionAwsKmsKeyId,
|
||||
|
||||
@@ -3,6 +3,8 @@ package s3api
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
func TestResolveDestinationMime(t *testing.T) {
|
||||
@@ -279,3 +281,30 @@ func TestProcessMetadataBytes_CopyInheritsSystemHeaders(t *testing.T) {
|
||||
t.Errorf("Content-Encoding = %q, want %q", got, "gzip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMetadataBytes_CopyAppliesRequestedSSES3Header(t *testing.T) {
|
||||
req := http.Header{}
|
||||
req.Set(s3_constants.AmzServerSideEncryption, s3_constants.SSEAlgorithmAES256)
|
||||
|
||||
out, err := processMetadataBytes(req, nil, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("processMetadataBytes returned error: %v", err)
|
||||
}
|
||||
if got := string(out[s3_constants.AmzServerSideEncryption]); got != s3_constants.SSEAlgorithmAES256 {
|
||||
t.Fatalf("%s = %q, want %q", s3_constants.AmzServerSideEncryption, got, s3_constants.SSEAlgorithmAES256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMetadataBytes_CopyPreservesSourceSSEWhenRequestOmitsHeader(t *testing.T) {
|
||||
existing := map[string][]byte{
|
||||
s3_constants.AmzServerSideEncryption: []byte(s3_constants.SSEAlgorithmKMS),
|
||||
}
|
||||
|
||||
out, err := processMetadataBytes(http.Header{}, existing, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("processMetadataBytes returned error: %v", err)
|
||||
}
|
||||
if got := string(out[s3_constants.AmzServerSideEncryption]); got != s3_constants.SSEAlgorithmKMS {
|
||||
t.Fatalf("%s = %q, want %q", s3_constants.AmzServerSideEncryption, got, s3_constants.SSEAlgorithmKMS)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user