s3: require dedicated object-lock permissions for x-amz-object-lock-* headers (#11492)

* s3: require dedicated object-lock permissions for x-amz-object-lock-* headers

PutObject, CreateMultipartUpload, and PostPolicy honor the retention and
legal-hold headers after only the route's s3:PutObject check, so a
write-only principal could pin a version under COMPLIANCE retention that
nobody can remove before its retain-until date. On AWS these headers
require s3:PutObjectRetention / s3:PutObjectLegalHold. validateObjectLockHeaders
is the shared funnel for all four call sites; it now authorizes the
corresponding dedicated action when each header is present.

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

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

* s3: record the verified POST-policy signer as the request identity

The handler authenticated the form policy signature but stored only the
signer's name, so downstream authorization (the object-lock header check)
re-authenticated the form-signed request as anonymous and evaluated the
wrong principal.

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Chris Lu
2026-09-27 20:17:12 +08:00
committed by GitHub
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent e57f8c4d87
commit a976b21010
5 changed files with 104 additions and 21 deletions
@@ -69,7 +69,7 @@ func (s3a *S3ApiServer) NewMultipartUploadHandler(w http.ResponseWriter, r *http
}
// Validate object lock headers before processing
if err := s3a.validateObjectLockHeaders(r, versioningEnabled); err != nil {
if err := s3a.validateObjectLockHeaders(r, bucket, object, versioningEnabled); err != nil {
glog.V(2).Infof("NewMultipartUploadHandler: object lock header validation failed for bucket %s, object %s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(err))
return
@@ -86,7 +86,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
return
}
if identity != nil {
r = r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), identity.Name))
r = r.WithContext(recordIdentityInContext(r, identity))
}
policyBytes, err := base64.StdEncoding.DecodeString(formValues.Get("Policy"))
@@ -160,7 +160,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if err := s3a.validateObjectLockHeaders(r, objectLockEnabled); err != nil {
if err := s3a.validateObjectLockHeaders(r, bucket, object, objectLockEnabled); err != nil {
glog.V(2).Infof("PostPolicyBucketHandler: object lock header validation failed for %s/%s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(err))
return
+37 -3
View File
@@ -45,6 +45,7 @@ var (
ErrObjectLockModeRequiresDate = errors.New("object lock mode requires retention until date")
ErrRetentionDateRequiresMode = errors.New("retention until date requires object lock mode")
ErrGovernanceBypassVersioningRequired = errors.New("governance bypass header can only be used on buckets with Object Lock enabled")
ErrObjectLockNotAuthorized = errors.New("object lock headers require the corresponding object lock permission")
ErrInvalidObjectLockDuration = errors.New("object lock duration must be greater than 0 days")
ErrObjectLockDurationExceeded = errors.New("object lock duration exceeds maximum allowed days")
ErrObjectLockConfigurationMissingEnabled = errors.New("object lock configuration must specify ObjectLockEnabled")
@@ -152,7 +153,7 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if validationErr := s3a.validateObjectLockHeaders(r, objectLockEnabled); validationErr != nil {
if validationErr := s3a.validateObjectLockHeaders(r, bucket, object, objectLockEnabled); validationErr != nil {
glog.V(2).Infof("PutObjectHandler: object lock header validation failed for %s/%s: %v", bucket, object, validationErr)
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(validationErr))
return
@@ -281,7 +282,7 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request)
}
// Validate object lock headers before processing
if err := s3a.validateObjectLockHeaders(r, objectLockEnabled); err != nil {
if err := s3a.validateObjectLockHeaders(r, bucket, object, objectLockEnabled); err != nil {
glog.V(2).Infof("PutObjectHandler: object lock header validation failed for bucket %s, object %s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(err))
return
@@ -2129,9 +2130,29 @@ func (s3a *S3ApiServer) applyBucketDefaultRetention(bucket string, entry *filer_
return nil
}
// checkObjectLockHeaderPermission reports whether the caller may set object
// lock through PutObject-style request headers. On AWS these headers require
// the dedicated s3:PutObjectRetention / s3:PutObjectLegalHold permissions;
// s3:PutObject alone must not be enough, or any writer can pin data with
// COMPLIANCE retention. Without auth there is nothing to check against.
func (s3a *S3ApiServer) checkObjectLockHeaderPermission(r *http.Request, bucket, object string, action Action) bool {
if s3a.iam == nil || !s3a.iam.isEnabled() {
return true
}
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
if identity == nil {
var errCode s3err.ErrorCode
identity, errCode, _ = s3a.iam.authenticateRequestInternal(r)
if errCode != s3err.ErrNone {
return false
}
}
return s3a.iam.authorizeObjectKeyAction(r, identity, r.Method, action, bucket, s3_constants.NormalizeObjectKey(object), "") == s3err.ErrNone
}
// validateObjectLockHeaders validates object lock headers in PUT requests
// objectLockEnabled should be true only if the bucket has Object Lock configured
func (s3a *S3ApiServer) validateObjectLockHeaders(r *http.Request, objectLockEnabled bool) error {
func (s3a *S3ApiServer) validateObjectLockHeaders(r *http.Request, bucket, object string, objectLockEnabled bool) error {
// Extract object lock headers from request
mode := r.Header.Get(s3_constants.AmzObjectLockMode)
retainUntilDateStr := r.Header.Get(s3_constants.AmzObjectLockRetainUntilDate)
@@ -2193,6 +2214,17 @@ func (s3a *S3ApiServer) validateObjectLockHeaders(r *http.Request, objectLockEna
return ErrGovernanceBypassVersioningRequired
}
if mode != "" || retainUntilDateStr != "" {
if !s3a.checkObjectLockHeaderPermission(r, bucket, object, s3_constants.ACTION_PUT_OBJECT_RETENTION) {
return ErrObjectLockNotAuthorized
}
}
if legalHold != "" {
if !s3a.checkObjectLockHeaderPermission(r, bucket, object, s3_constants.ACTION_PUT_OBJECT_LEGAL_HOLD) {
return ErrObjectLockNotAuthorized
}
}
return nil
}
@@ -2232,6 +2264,8 @@ func mapValidationErrorToS3Error(err error) s3err.ErrorCode {
// For governance bypass on non-versioned bucket, return InvalidRequest
// This matches the test expectations
return s3err.ErrInvalidRequest
case errors.Is(err, ErrObjectLockNotAuthorized):
return s3err.ErrAccessDenied
case errors.Is(err, ErrMalformedXML):
// For malformed XML in request body, return MalformedXML
// This matches the test expectations for invalid retention mode and legal hold status
@@ -2,9 +2,11 @@ package s3api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
@@ -88,6 +90,53 @@ func TestGovernanceBypassDoesNotInheritDeleteObjectVersion(t *testing.T) {
}
}
// The object-lock request headers on PutObject/CreateMultipartUpload must be
// authorized as s3:PutObjectRetention / s3:PutObjectLegalHold; s3:PutObject
// alone used to set retention and legal hold on new versions.
func TestObjectLockHeadersRequireDedicatedActions(t *testing.T) {
iam := &IdentityAccessManagement{isAuthEnabled: true}
require.NoError(t, iam.PutPolicy("Writer",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":"arn:aws:s3:::worm/*"}]}`))
require.NoError(t, iam.PutPolicy("Locked",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject","s3:PutObjectRetention","s3:PutObjectLegalHold"],"Resource":"arn:aws:s3:::worm/*"}]}`))
s3a := &S3ApiServer{iam: iam}
retainUntil := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
for _, tc := range []struct {
name string
policy string
headers map[string]string
wantErr error
}{
{"writer sets retention", "Writer",
map[string]string{s3_constants.AmzObjectLockMode: "COMPLIANCE", s3_constants.AmzObjectLockRetainUntilDate: retainUntil},
ErrObjectLockNotAuthorized},
{"writer sets legal hold", "Writer",
map[string]string{s3_constants.AmzObjectLockLegalHold: s3_constants.LegalHoldOn},
ErrObjectLockNotAuthorized},
{"writer plain put", "Writer", nil, nil},
{"locked principal sets both", "Locked",
map[string]string{s3_constants.AmzObjectLockMode: "GOVERNANCE", s3_constants.AmzObjectLockRetainUntilDate: retainUntil, s3_constants.AmzObjectLockLegalHold: s3_constants.LegalHoldOn},
nil},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "http://localhost:8333/worm/doc.txt", nil)
req = mux.SetURLVars(req, map[string]string{"bucket": "worm", "object": "doc.txt"})
for k, v := range tc.headers {
req.Header.Set(k, v)
}
req = req.WithContext(s3_constants.SetIdentityInContext(req.Context(), &Identity{
Name: "caller",
PolicyNames: []string{tc.policy},
Account: &Account{Id: "test-account"},
}))
if err := s3a.validateObjectLockHeaders(req, "worm", "doc.txt", true); !errors.Is(err, tc.wantErr) {
t.Errorf("validateObjectLockHeaders() = %v, want %v", err, tc.wantErr)
}
})
}
}
func TestGovernanceBypassUsesBodyObjectKey(t *testing.T) {
iam := newTestIAM()
iam.identities[0].Actions = []Action{
+15 -15
View File
@@ -410,7 +410,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req.Header.Set(s3_constants.AmzObjectLockMode, "COMPLIANCE")
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339))
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
@@ -420,7 +420,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req.Header.Set(s3_constants.AmzObjectLockMode, "GOVERNANCE")
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339))
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
@@ -428,7 +428,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
req.Header.Set(s3_constants.AmzObjectLockLegalHold, "ON")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
@@ -436,7 +436,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
req.Header.Set(s3_constants.AmzObjectLockLegalHold, "OFF")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
@@ -446,7 +446,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
retainUntilDate := time.Now().Add(24 * time.Hour)
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339))
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidObjectLockMode))
})
@@ -455,7 +455,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
req.Header.Set(s3_constants.AmzObjectLockLegalHold, "INVALID_STATUS")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidLegalHoldStatus))
})
@@ -466,7 +466,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
retainUntilDate := time.Now().Add(24 * time.Hour)
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339))
err := s3a.validateObjectLockHeaders(req, false) // non-versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", false) // non-versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrObjectLockVersioningRequired))
})
@@ -476,7 +476,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req.Header.Set(s3_constants.AmzObjectLockMode, "COMPLIANCE")
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, "invalid-date-format")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidRetentionDateFormat))
})
@@ -487,7 +487,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
pastDate := time.Now().Add(-24 * time.Hour)
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, pastDate.Format(time.RFC3339))
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrRetentionDateMustBeFuture))
})
@@ -496,7 +496,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
req.Header.Set(s3_constants.AmzObjectLockMode, "COMPLIANCE")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrObjectLockModeRequiresDate))
})
@@ -506,7 +506,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
retainUntilDate := time.Now().Add(24 * time.Hour)
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339))
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrRetentionDateRequiresMode))
})
@@ -515,7 +515,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
req.Header.Set("x-amz-bypass-governance-retention", "true")
err := s3a.validateObjectLockHeaders(req, false) // non-versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", false) // non-versioned bucket
assert.Error(t, err)
assert.True(t, errors.Is(err, ErrGovernanceBypassVersioningRequired))
})
@@ -524,7 +524,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
req.Header.Set("x-amz-bypass-governance-retention", "true")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
@@ -532,7 +532,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req := httptest.NewRequest("PUT", "/bucket/object", nil)
// No object lock headers set
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
@@ -543,7 +543,7 @@ func TestValidateObjectLockHeaders(t *testing.T) {
req.Header.Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339))
req.Header.Set(s3_constants.AmzObjectLockLegalHold, "ON")
err := s3a.validateObjectLockHeaders(req, true) // versioned bucket
err := s3a.validateObjectLockHeaders(req, "bucket", "object", true) // versioned bucket
assert.NoError(t, err)
})
}