Merge pull request #2434 from versity/sis/post-object-lock

fix: enforce object lock on browser-based `POST` object uploads
This commit is contained in:
Ben McClelland
2026-09-23 18:05:55 -07:00
committed by GitHub
5 changed files with 180 additions and 6 deletions
+9
View File
@@ -220,6 +220,15 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
}, err
}
err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, IsBucketPublic, c.be, c.iam, true)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
res, err := c.be.PutObject(ctx.RequestCtx(), s3response.PutObjectInput{
Bucket: &bucket,
Key: &key,
+37
View File
@@ -484,6 +484,27 @@ func TestS3ApiController_POSTObject(t *testing.T) {
err: s3err.GetMetadataTooLargeErr(2053, 2048),
},
},
{
name: "overwriting a locked object",
input: testInput{
// object lock enabled on the bucket; the mock reports a
// legal hold on every object
extraMockResp: []byte(`{"Enabled":true}`),
locals: postObjectLocalsForTest(middlewares.PostObjectResult{
Fields: baseFields,
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
}),
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrObjectLocked),
},
},
{
name: "backend returns error",
input: testInput{
@@ -734,6 +755,22 @@ func TestS3ApiController_POSTObject(t *testing.T) {
GetBucketPolicyFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrAccessDenied)
},
GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
return s3response.GetBucketVersioningOutput{}, s3err.GetAPIError(s3err.ErrNotImplemented)
},
GetObjectLockConfigurationFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
if tt.input.extraMockResp != nil {
return tt.input.extraMockResp.([]byte), nil
}
return nil, s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound)
},
GetObjectRetentionFunc: func(contextMoqParam context.Context, bucket, object, versionId string) ([]byte, error) {
return nil, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)
},
GetObjectLegalHoldFunc: func(contextMoqParam context.Context, bucket, object, versionId string) (*bool, error) {
legalHold := true
return &legalHold, nil
},
}
ctrl := S3ApiController{
+116 -5
View File
@@ -15,8 +15,11 @@
package integration
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -52,7 +55,7 @@ func WORMProtection_bucket_object_lock_configuration_compliance_mode(s *S3Conf)
return err
}
if err := checkWORMProtection(s3client, bucket, object); err != nil {
if err := checkWORMProtection(s, s3client, bucket, object); err != nil {
return err
}
return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: object, isCompliance: true}})
@@ -87,7 +90,7 @@ func WORMProtection_bucket_object_lock_configuration_governance_mode(s *S3Conf)
return err
}
if err := checkWORMProtection(s3client, bucket, object); err != nil {
if err := checkWORMProtection(s, s3client, bucket, object); err != nil {
return err
}
return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: object}})
@@ -287,7 +290,7 @@ func WORMProtection_object_lock_retention_compliance_locked(s *S3Conf) error {
return err
}
if err := checkWORMProtection(s3client, bucket, object); err != nil {
if err := checkWORMProtection(s, s3client, bucket, object); err != nil {
return err
}
@@ -320,7 +323,7 @@ func WORMProtection_object_lock_retention_governance_locked(s *S3Conf) error {
return err
}
if err := checkWORMProtection(s3client, bucket, object); err != nil {
if err := checkWORMProtection(s, s3client, bucket, object); err != nil {
return err
}
return cleanupLockedObjects(s3client, bucket, []objToDelete{{key: object}})
@@ -473,6 +476,77 @@ func WORMProtection_object_lock_retention_governance_bypass_overwrite_copy(s *S3
}, withLock())
}
func WORMProtection_object_lock_retention_governance_bypass_overwrite_post(s *S3Conf) error {
testName := "WORMProtection_object_lock_retention_governance_bypass_overwrite_post"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
object := "my-obj"
_, err := putObjects(s3client, []string{object}, bucket)
if err != nil {
return err
}
err = lockObject(s3client, objectLockModeGovernance, bucket, object, "")
if err != nil {
return err
}
policy := genPolicyDoc("Allow", fmt.Sprintf(`"%s"`, s.awsID), `["s3:BypassGovernanceRetention"]`, fmt.Sprintf(`"arn:aws:s3:::%v/*"`, bucket))
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: &bucket,
Policy: &policy,
})
cancel()
if err != nil {
return err
}
// overwrite the locked object with a new object with POST object
data := []byte("new object data")
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: object,
s3Conf: s,
fileContent: data,
})
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("expected status 204, instead got %d", resp.StatusCode)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
defer cancel()
out, err := s3client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket,
Key: &object,
})
if err != nil {
return err
}
defer out.Body.Close()
gotData, err := io.ReadAll(out.Body)
if err != nil {
return err
}
if getString(out.ETag) != resp.Header.Get("ETag") {
return fmt.Errorf("expected the object ETag to be %s, instead got %s", resp.Header.Get("ETag"), getString(out.ETag))
}
if !bytes.Equal(gotData, data) {
return fmt.Errorf("expected the object data to be %q, instead got %q", data, gotData)
}
return nil
}, withLock())
}
func WORMProtection_unable_to_overwrite_locked_object_put(s *S3Conf) error {
testName := "WORMProtection_unable_to_overwrite_locked_object_put"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -597,6 +671,43 @@ func WORMProtection_unable_to_overwrite_locked_object_mp(s *S3Conf) error {
}, withLock())
}
func WORMProtection_unable_to_overwrite_locked_object_post(s *S3Conf) error {
testName := "WORMProtection_unable_to_overwrite_locked_object_post"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
object := "my-obj"
_, err := putObjects(s3client, []string{object}, bucket)
if err != nil {
return err
}
err = lockObject(s3client, objectLockModeLegalHold, bucket, object, "")
if err != nil {
return err
}
// overwrite the locked object with a new object with POST object
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: object,
s3Conf: s,
fileContent: []byte("new object data"),
})
if err != nil {
return err
}
if err := checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil {
return err
}
return cleanupLockedObjects(s3client, bucket, []objToDelete{
{
key: object,
removeOnlyLeglHold: true,
},
})
}, withLock())
}
func WORMProtection_object_lock_retention_governance_bypass_delete(s *S3Conf) error {
testName := "WORMProtection_object_lock_retention_governance_bypass_delete"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -766,7 +877,7 @@ func WORMProtection_root_bypass_governance_retention_delete_object(s *S3Conf) er
return err
}
if err := checkWORMProtection(s3client, bucket, obj); err != nil {
if err := checkWORMProtection(s, s3client, bucket, obj); err != nil {
return err
}
+4
View File
@@ -896,9 +896,11 @@ func TestWORMProtection(ts *TestState) {
ts.Run(WORMProtection_object_lock_retention_governance_bypass_overwrite_put)
ts.Run(WORMProtection_object_lock_retention_governance_bypass_overwrite_copy)
ts.Run(WORMProtection_object_lock_retention_governance_bypass_overwrite_mp)
ts.Run(WORMProtection_object_lock_retention_governance_bypass_overwrite_post)
ts.Run(WORMProtection_unable_to_overwrite_locked_object_put)
ts.Run(WORMProtection_unable_to_overwrite_locked_object_copy)
ts.Run(WORMProtection_unable_to_overwrite_locked_object_mp)
ts.Run(WORMProtection_unable_to_overwrite_locked_object_post)
ts.Run(WORMProtection_object_lock_retention_governance_bypass_delete)
ts.Run(WORMProtection_object_lock_retention_governance_bypass_delete_mul)
ts.Run(WORMProtection_object_lock_legal_hold_locked)
@@ -3437,9 +3439,11 @@ func GetIntTests() IntTests {
"WORMProtection_object_lock_retention_governance_bypass_overwrite_put": WORMProtection_object_lock_retention_governance_bypass_overwrite_put,
"WORMProtection_object_lock_retention_governance_bypass_overwrite_copy": WORMProtection_object_lock_retention_governance_bypass_overwrite_copy,
"WORMProtection_object_lock_retention_governance_bypass_overwrite_mp": WORMProtection_object_lock_retention_governance_bypass_overwrite_mp,
"WORMProtection_object_lock_retention_governance_bypass_overwrite_post": WORMProtection_object_lock_retention_governance_bypass_overwrite_post,
"WORMProtection_unable_to_overwrite_locked_object_put": WORMProtection_unable_to_overwrite_locked_object_put,
"WORMProtection_unable_to_overwrite_locked_object_copy": WORMProtection_unable_to_overwrite_locked_object_copy,
"WORMProtection_unable_to_overwrite_locked_object_mp": WORMProtection_unable_to_overwrite_locked_object_mp,
"WORMProtection_unable_to_overwrite_locked_object_post": WORMProtection_unable_to_overwrite_locked_object_post,
"WORMProtection_object_lock_retention_governance_bypass_delete": WORMProtection_object_lock_retention_governance_bypass_delete,
"WORMProtection_object_lock_retention_governance_bypass_delete_mul": WORMProtection_object_lock_retention_governance_bypass_delete_mul,
"WORMProtection_object_lock_legal_hold_locked": WORMProtection_object_lock_legal_hold_locked,
+14 -1
View File
@@ -2253,7 +2253,7 @@ func putBucketVersioningStatus(client *s3.Client, bucket string, status types.Bu
return err
}
func checkWORMProtection(client *s3.Client, bucket, object string) error {
func checkWORMProtection(s *S3Conf, client *s3.Client, bucket, object string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket,
@@ -2264,6 +2264,19 @@ func checkWORMProtection(client *s3.Client, bucket, object string) error {
return err
}
resp, err := sendPostObject(PostRequestConfig{
bucket: bucket,
key: object,
s3Conf: s,
fileContent: []byte("overwrite"),
})
if err != nil {
return err
}
if err := checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrObjectLocked)); err != nil {
return fmt.Errorf("POST object overwrite: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,