fix: require s3:PutObjectTagging for tagged browser-based POST object uploads

`POSTObject` authorized only `s3:PutObject`, even when the form's `tagging` field set tags on the uploaded object. `PutObject`, `CopyObject` and `CreateMultipartUpload` also require `s3:PutObjectTagging` when tags are sent, so a caller without that permission could tag objects through `POST` in a way `PutObject` refuses. S3 enforces the permission for `POST` too: a user granted only `s3:PutObject` gets `AccessDenied` for `s3:PutObjectTagging` on the object ARN.

`POSTObject` now adds `s3:PutObjectTagging` to the `verifyAccess` actions when the parsed tag set is not empty, in the same single check as `s3:PutObject`, matching `PutObject`. A `tagging` field with an empty `TagSet` still needs only `s3:PutObject`, as on S3.

The access check now runs after the form is validated, instead of before the `POST` policy is parsed. S3 validates the policy and its conditions, the tagging, the checksum fields, the metadata size and the website redirect location before it authorizes the upload, so a caller missing a permission now gets the validation error for an invalid form instead of `AccessDenied`.
This commit is contained in:
niksis02
2026-09-24 22:31:08 +04:00
parent 84e8b0dc56
commit aa1e82b66c
6 changed files with 350 additions and 25 deletions
+29 -22
View File
@@ -144,28 +144,6 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
key := parsed.Fields["key"]
// A POST upload is an s3:PutObject on the object named by the form's
// key field, so it is authorized against that object's ARN — the same
// resource PutObject is — not the bucket's.
err := c.verifyAccess(ctx,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Object: key,
Actions: []auth.Action{auth.PutObjectAction},
IsPublicRequest: IsBucketPublic,
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// parse POST policy — absent for anonymous uploads to public buckets
if !IsBucketPublic {
policyBase64 := parsed.Fields["policy"]
@@ -198,6 +176,7 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
// to pass PutObject, which expects the tagging to be a query string
var tagging string
if taggingXML, ok := parsed.Fields["tagging"]; ok {
var err error
tagging, err = utils.ConvertTaggingXMLToQueryString([]byte(taggingXML))
if err != nil {
return &Response{
@@ -237,6 +216,34 @@ func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
}, err
}
// A POST upload is an s3:PutObject on the object named by the form's
// key field, so it is authorized against that object's ARN — the same
// resource PutObject is — not the bucket's. Tagging the object also
// takes s3:PutObjectTagging, but only for a non-empty tag set
actions := []auth.Action{auth.PutObjectAction}
if tagging != "" {
actions = append(actions, auth.PutObjectTaggingAction)
}
err = c.verifyAccess(ctx,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Object: key,
Actions: actions,
IsPublicRequest: IsBucketPublic,
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, IsBucketPublic, c.be, c.iam, true)
if err != nil {
return &Response{
+3 -3
View File
@@ -421,9 +421,9 @@ func TestS3ApiController_POSTObject(t *testing.T) {
},
utils.ContextKeyRegion: "us-east-1",
utils.ContextKeyObjectPostResult: middlewares.PostObjectResult{
Fields: map[string]string{
"key": "key",
},
Fields: baseFields,
FileRdr: newMockFileReader("payload"),
ContentLength: int64(len("payload")),
},
},
},