Files
seaweedfs/weed/s3api/s3api_copy_validation.go
T
838c554e33 s3api: reject SSE headers PutObject cannot honor, as CopyObject does (#11642)
* s3api: reject SSE headers PutObject cannot honor, as CopyObject does

PutObject and CreateMultipartUpload did not check the server-side
encryption headers they were given. An x-amz-server-side-encryption
value that names no method, such as "aes:kms", matched none of the SSE
paths, so the object was stored unencrypted and the request answered
200. SSE-C together with x-amz-server-side-encryption was also accepted,
and one of the two silently won.

CopyObject already rejects both through validateEncryptionCompatibility.
Run the same check, with the same error codes, before PutObject and
CreateMultipartUpload store anything.

* s3api: answer rejected SSE headers with InvalidArgument, as S3 does

S3 rejects an unknown x-amz-server-side-encryption value and SSE-C
combined with another method with InvalidArgument. PutObject and
CreateMultipartUpload now return that code, with S3's messages, through
two new error codes; CopyObject keeps its own.

Also run ceph/s3-tests' test_put_obj_enc_conflict_c_s3, _c_kms and
_bad_enc_kms in CI, which check both handlers' responses end to end.

* s3api: close the remaining ways a PUT could skip requested encryption

- A repeated x-amz-server-side-encryption header is rejected: the
  encryption paths apply only the first value, so extra values could
  hide the method the client asked for.
- KMS options (key id, encryption context, bucket key) are rejected
  unless the method is aws:kms, matching the S3 InvalidArgument error.
- CreateMultipartUpload validates before auto-create, so a refused
  upload cannot leave a bucket behind.
- Directory markers encrypt their inline content through the shared
  SSE path and record the same entry metadata as regular objects,
  instead of storing requested-encrypted bytes in plaintext.

* s3api: read back what the SSE marker write stores

- Repeated SSE-C and KMS option headers are rejected alongside a
  repeated x-amz-server-side-encryption, closing the same first-value
  bypass for customer-key and KMS fields.
- Algorithm validity is checked before KMS options so an unsupported
  value keeps the "not supported" error.
- serveDirectoryContent decrypts marker content with the stored SSE
  metadata and returns the SSE headers, so an encrypted marker reads
  back what was written; its Content-Length now reflects the bytes
  actually served.

* s3api: HEAD of a marker skips decryption and keeps the stored size

HEAD returns no body, so it now runs only the SSE-C key check instead of
decrypting — matching HeadObjectHandler and avoiding a KMS round-trip —
and chunk-backed directory entries report Attributes.FileSize again
rather than the length of their (empty) inline content.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3api: keep GET Content-Length to the bytes serveDirectoryContent writes

The FileSize override described chunk-backed markers on HEAD, but GET
sends only the inline content, so it promised bytes it never wrote.

* s3api: stream a chunk-backed directory on GET like any object

A directory promoted over an uploaded object keeps the object chunks
with empty inline content, so serving only entry.Content made GET
deliver nothing while HEAD reported FileSize. GET now routes those
entries through the regular volume-server stream, keeping the two
methods consistent.

* s3api: answer a busy volume read with RequestBytesExceed

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 12:43:04 +08:00

381 lines
11 KiB
Go

package s3api
import (
"errors"
"fmt"
"net/http"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// CopyValidationError represents validation errors during copy operations
type CopyValidationError struct {
Code s3err.ErrorCode
Message string
}
func (e *CopyValidationError) Error() string {
return e.Message
}
// ValidateCopyEncryption performs comprehensive validation of copy encryption parameters
func ValidateCopyEncryption(srcMetadata map[string][]byte, headers http.Header) error {
// Validate SSE-C copy requirements
if err := validateSSECCopyRequirements(srcMetadata, headers); err != nil {
return err
}
// Validate SSE-KMS copy requirements
if err := validateSSEKMSCopyRequirements(srcMetadata, headers); err != nil {
return err
}
// Validate incompatible encryption combinations
if err := validateEncryptionCompatibility(headers); err != nil {
return err
}
return nil
}
// validateSSECCopyRequirements validates SSE-C copy header requirements
func validateSSECCopyRequirements(srcMetadata map[string][]byte, headers http.Header) error {
srcIsSSEC := IsSSECEncrypted(srcMetadata)
hasCopyHeaders := hasSSECCopyHeaders(headers)
hasSSECHeaders := hasSSECHeaders(headers)
// If source is SSE-C encrypted, copy headers are required
if srcIsSSEC && !hasCopyHeaders {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C encrypted source requires copy source encryption headers",
}
}
// If copy headers are provided, source must be SSE-C encrypted
if hasCopyHeaders && !srcIsSSEC {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C copy headers provided but source is not SSE-C encrypted",
}
}
// Validate copy header completeness
if hasCopyHeaders {
if err := validateSSECCopyHeaderCompleteness(headers); err != nil {
return err
}
}
// Validate destination SSE-C headers if present
if hasSSECHeaders {
if err := validateSSECHeaderCompleteness(headers); err != nil {
return err
}
}
return nil
}
// validateSSEKMSCopyRequirements validates SSE-KMS copy requirements
func validateSSEKMSCopyRequirements(srcMetadata map[string][]byte, headers http.Header) error {
dstIsSSEKMS := IsSSEKMSRequest(&http.Request{Header: headers})
// Validate KMS key ID format if provided
if dstIsSSEKMS {
keyID := headers.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId)
if keyID != "" && !isValidKMSKeyID(keyID) {
return &CopyValidationError{
Code: s3err.ErrKMSKeyNotFound,
Message: fmt.Sprintf("Invalid KMS key ID format: %s", keyID),
}
}
}
// Validate encryption context format if provided
if contextHeader := headers.Get(s3_constants.AmzServerSideEncryptionContext); contextHeader != "" {
if !dstIsSSEKMS {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "Encryption context can only be used with SSE-KMS",
}
}
// Validate base64 encoding and JSON format
if err := validateEncryptionContext(contextHeader); err != nil {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: fmt.Sprintf("Invalid encryption context: %v", err),
}
}
}
return nil
}
// validateEncryptionCompatibility validates that encryption methods are not conflicting
func validateEncryptionCompatibility(headers http.Header) error {
// A repeated header is rejected rather than deduped: the encryption paths
// apply only the first value, so extra values could hide the method the
// client actually asked for.
for _, name := range []string{
s3_constants.AmzServerSideEncryption,
s3_constants.AmzServerSideEncryptionCustomerAlgorithm,
s3_constants.AmzServerSideEncryptionCustomerKey,
s3_constants.AmzServerSideEncryptionCustomerKeyMD5,
s3_constants.AmzServerSideEncryptionAwsKmsKeyId,
s3_constants.AmzServerSideEncryptionContext,
s3_constants.AmzServerSideEncryptionBucketKeyEnabled,
} {
if len(headers.Values(name)) > 1 {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: fmt.Sprintf("Multiple %s headers specified - only one is allowed", name),
}
}
}
sseAlgorithm := headers.Get(s3_constants.AmzServerSideEncryption)
hasSSEC := hasSSECHeaders(headers)
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),
}
}
// KMS options only apply to aws:kms; with another method or none they name
// no method at all.
if !hasSSEKMS && hasHeaderValue(headers,
s3_constants.AmzServerSideEncryptionAwsKmsKeyId,
s3_constants.AmzServerSideEncryptionContext,
s3_constants.AmzServerSideEncryptionBucketKeyEnabled) {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "KMS encryption options require the aws:kms encryption method",
}
}
// Only one encryption method should be specified
encryptionCount := 0
for _, specified := range []bool{hasSSEC, hasSSEKMS, hasSSES3} {
if specified {
encryptionCount++
}
}
if encryptionCount > 1 {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "Multiple encryption methods specified - only one is allowed",
}
}
return nil
}
// ValidateRequestEncryption rejects PutObject or CreateMultipartUpload
// encryption headers that no single method can honor, with the InvalidArgument
// codes S3 returns for them.
func ValidateRequestEncryption(headers http.Header) s3err.ErrorCode {
err := validateEncryptionCompatibility(headers)
if err == nil {
return s3err.ErrNone
}
var validationErr *CopyValidationError
if errors.As(err, &validationErr) && validationErr.Code == s3err.ErrInvalidEncryptionAlgorithm {
return s3err.ErrInvalidEncryptionMethod
}
return s3err.ErrIncompatibleEncryptionMethod
}
// validateSSECCopyHeaderCompleteness validates that all required SSE-C copy headers are present
func validateSSECCopyHeaderCompleteness(headers http.Header) error {
algorithm := headers.Get(s3_constants.AmzCopySourceServerSideEncryptionCustomerAlgorithm)
key := headers.Get(s3_constants.AmzCopySourceServerSideEncryptionCustomerKey)
keyMD5 := headers.Get(s3_constants.AmzCopySourceServerSideEncryptionCustomerKeyMD5)
if algorithm == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C copy customer algorithm header is required",
}
}
if key == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C copy customer key header is required",
}
}
if keyMD5 == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C copy customer key MD5 header is required",
}
}
// Validate algorithm
if algorithm != "AES256" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: fmt.Sprintf("Unsupported SSE-C algorithm: %s", algorithm),
}
}
return nil
}
// validateSSECHeaderCompleteness validates that all required SSE-C headers are present
func validateSSECHeaderCompleteness(headers http.Header) error {
algorithm := headers.Get(s3_constants.AmzServerSideEncryptionCustomerAlgorithm)
key := headers.Get(s3_constants.AmzServerSideEncryptionCustomerKey)
keyMD5 := headers.Get(s3_constants.AmzServerSideEncryptionCustomerKeyMD5)
if algorithm == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C customer algorithm header is required",
}
}
if key == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C customer key header is required",
}
}
if keyMD5 == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "SSE-C customer key MD5 header is required",
}
}
// Validate algorithm
if algorithm != "AES256" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: fmt.Sprintf("Unsupported SSE-C algorithm: %s", algorithm),
}
}
return nil
}
// Helper functions for header detection
func hasHeaderValue(headers http.Header, names ...string) bool {
for _, name := range names {
for _, value := range headers.Values(name) {
if value != "" {
return true
}
}
}
return false
}
func hasSSECCopyHeaders(headers http.Header) bool {
return hasHeaderValue(headers,
s3_constants.AmzCopySourceServerSideEncryptionCustomerAlgorithm,
s3_constants.AmzCopySourceServerSideEncryptionCustomerKey,
s3_constants.AmzCopySourceServerSideEncryptionCustomerKeyMD5)
}
func hasSSECHeaders(headers http.Header) bool {
return hasHeaderValue(headers,
s3_constants.AmzServerSideEncryptionCustomerAlgorithm,
s3_constants.AmzServerSideEncryptionCustomerKey,
s3_constants.AmzServerSideEncryptionCustomerKeyMD5)
}
// validateEncryptionContext validates the encryption context header format
func validateEncryptionContext(contextHeader string) error {
// This would validate base64 encoding and JSON format
// Implementation would decode base64 and parse JSON
// For now, just check it's not empty
if contextHeader == "" {
return fmt.Errorf("encryption context cannot be empty")
}
return nil
}
// ValidateCopySource validates the copy source path.
func ValidateCopySource(copySource string, srcBucket, srcObject string) error {
return validateCopySource(copySource, srcBucket, srcObject, "")
}
func validateCopySource(copySource string, srcBucket, srcObject, srcVersionId string) error {
if copySource == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidCopySource,
Message: "Copy source header is required",
}
}
if srcBucket == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidCopySource,
Message: "Source bucket cannot be empty",
}
}
if srcObject == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidCopySource,
Message: "Source object cannot be empty",
}
}
// `.`/`..` segments are collapsed by the filer's path join; reject them as
// IsValidObjectKey does for the request URL so the source stays in-bucket.
if !s3_constants.IsValidBucketName(srcBucket) || !s3_constants.IsValidObjectKey(srcObject) {
return &CopyValidationError{
Code: s3err.ErrInvalidCopySource,
Message: "Copy source contains invalid path segments",
}
}
if !isValidVersionID(srcVersionId) {
return &CopyValidationError{
Code: s3err.ErrInvalidCopySource,
Message: "Copy source contains an invalid version ID",
}
}
return nil
}
// ValidateCopyDestination validates the copy destination
func ValidateCopyDestination(dstBucket, dstObject string) error {
if dstBucket == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "Destination bucket cannot be empty",
}
}
if dstObject == "" {
return &CopyValidationError{
Code: s3err.ErrInvalidRequest,
Message: "Destination object cannot be empty",
}
}
return nil
}
// MapCopyValidationError maps validation errors to appropriate S3 error codes
func MapCopyValidationError(err error) s3err.ErrorCode {
if validationErr, ok := err.(*CopyValidationError); ok {
return validationErr.Code
}
return s3err.ErrInvalidRequest
}