From aa1e82b66c1263a286f742a8775cf1342f368c2e Mon Sep 17 00:00:00 2001 From: niksis02 Date: Thu, 24 Sep 2026 22:28:32 +0400 Subject: [PATCH] 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`. --- s3api/controllers/bucket-post.go | 51 +++-- s3api/controllers/bucket-post_test.go | 6 +- tests/integration/PostObject.go | 232 +++++++++++++++++++++ tests/integration/group-tests.go | 8 + tests/integration/s3_iam_access_control.go | 77 +++++++ tests/integration/s3_iam_utils.go | 1 + 6 files changed, 350 insertions(+), 25 deletions(-) diff --git a/s3api/controllers/bucket-post.go b/s3api/controllers/bucket-post.go index 72611ba0..1ca7dce7 100644 --- a/s3api/controllers/bucket-post.go +++ b/s3api/controllers/bucket-post.go @@ -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{ diff --git a/s3api/controllers/bucket-post_test.go b/s3api/controllers/bucket-post_test.go index 14f84796..4e305eb2 100644 --- a/s3api/controllers/bucket-post_test.go +++ b/s3api/controllers/bucket-post_test.go @@ -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")), }, }, }, diff --git a/tests/integration/PostObject.go b/tests/integration/PostObject.go index e8609f94..b047db2b 100644 --- a/tests/integration/PostObject.go +++ b/tests/integration/PostObject.go @@ -428,6 +428,238 @@ func PostObject_bucket_policy_explicit_deny(s *S3Conf) error { }) } +func PostObject_tagging_requires_put_object_tagging(s *S3Conf) error { + testName := "PostObject_tagging_requires_put_object_tagging" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + key := "my-obj" + taggingXML := `envtest` + post := func() (*http.Response, error) { + return sendPostObject(PostRequestConfig{ + bucket: bucket, + key: key, + access: testuser.access, + secret: testuser.secret, + s3Conf: s, + fileContent: []byte("data"), + policyConditions: []any{ + []any{"eq", "$tagging", taggingXML}, + }, + extraFields: map[string]string{ + "tagging": taggingXML, + }, + }) + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + }); err != nil { + return err + } + + resp, err := post() + if err != nil { + return err + } + if err := checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrAccessDenied)); err != nil { + return fmt.Errorf("POST with s3:PutObject only: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &key, + }) + cancel() + if err := checkSdkApiErr(err, "NotFound"); err != nil { + return fmt.Errorf("expected the denied POST not to create %s: %w", key, err) + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: []string{"s3:PutObject", "s3:PutObjectTagging"}, + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + }); err != nil { + return err + } + + resp, err = post() + if err != nil { + return err + } + if err := checkPostObjectSuccess(resp); err != nil { + return fmt.Errorf("POST with s3:PutObject and s3:PutObjectTagging: %w", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + tagging, err := s3client.GetObjectTagging(ctx, &s3.GetObjectTaggingInput{ + Bucket: &bucket, + Key: &key, + }) + cancel() + if err != nil { + return err + } + + expectedTagging := []types.Tag{{Key: getPtr("env"), Value: getPtr("test")}} + if !areTagsSame(expectedTagging, tagging.TagSet) { + return fmt.Errorf("expected %v tagging, instead got %v", expectedTagging, tagging.TagSet) + } + + return nil + }) +} + +// PostObject_tagging_bucket_policy_explicit_deny covers a Deny on +// s3:PutObjectTagging scoped to a key prefix. It refuses a tagged POST +// upload to a key under that prefix although a bucket-wide Allow grants +// both actions, and leaves an untagged upload there, which needs only +// s3:PutObject, alone. +func PostObject_tagging_bucket_policy_explicit_deny(s *S3Conf) error { + testName := "PostObject_tagging_bucket_policy_explicit_deny" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + if err := putBucketPolicyDoc(s, bucket, + bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: []string{"s3:PutObject", "s3:PutObjectTagging"}, + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + }, + bucketStatement{ + Effect: "Deny", + Principal: testuser.access, + Action: "s3:PutObjectTagging", + Resource: fmt.Sprintf("arn:aws:s3:::%s/private/*", bucket), + }, + ); err != nil { + return err + } + + cfg := func(key string) PostRequestConfig { + return PostRequestConfig{ + bucket: bucket, + key: key, + access: testuser.access, + secret: testuser.secret, + s3Conf: s, + fileContent: []byte("data"), + } + } + taggingXML := `envtest` + tagged := func(key string) PostRequestConfig { + c := cfg(key) + c.policyConditions = []any{ + []any{"eq", "$tagging", taggingXML}, + } + c.extraFields = map[string]string{ + "tagging": taggingXML, + } + return c + } + + allowedKey := "public/my-obj" + resp, err := sendPostObject(tagged(allowedKey)) + if err != nil { + return err + } + if err := checkPostObjectSuccess(resp); err != nil { + return fmt.Errorf("tagged POST %s: %w", allowedKey, err) + } + + deniedKey := "private/my-obj" + resp, err = sendPostObject(tagged(deniedKey)) + if err != nil { + return err + } + if err := checkHTTPResponseApiErr(resp, s3err.GetExplicitDenyAccessErr(testuser.access, "s3:PutObjectTagging", + fmt.Sprintf("arn:aws:s3:::%s/%s", bucket, deniedKey), "a resource-based policy")); err != nil { + return fmt.Errorf("tagged POST %s: %w", deniedKey, err) + } + + resp, err = sendPostObject(cfg(deniedKey)) + if err != nil { + return err + } + if err := checkPostObjectSuccess(resp); err != nil { + return fmt.Errorf("untagged POST %s: %w", deniedKey, err) + } + return nil + }) +} + +// PostObject_empty_tag_set_requires_only_put_object covers a tagging field +// whose tag set is empty. It tags nothing, so the upload needs only +// s3:PutObject, as it does on S3. +func PostObject_empty_tag_set_requires_only_put_object(s *S3Conf) error { + testName := "PostObject_empty_tag_set_requires_only_put_object" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + testuser := getUser("user") + if err := createUsers(s, []user{testuser}); err != nil { + return err + } + + if err := putBucketPolicyDoc(s, bucket, bucketStatement{ + Effect: "Allow", + Principal: testuser.access, + Action: "s3:PutObject", + Resource: fmt.Sprintf("arn:aws:s3:::%s/*", bucket), + }); err != nil { + return err + } + + key := "my-obj" + taggingXML := `` + resp, err := sendPostObject(PostRequestConfig{ + bucket: bucket, + key: key, + access: testuser.access, + secret: testuser.secret, + s3Conf: s, + fileContent: []byte("data"), + policyConditions: []any{ + []any{"eq", "$tagging", taggingXML}, + }, + extraFields: map[string]string{ + "tagging": taggingXML, + }, + }) + if err != nil { + return err + } + if err := checkPostObjectSuccess(resp); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + tagging, err := s3client.GetObjectTagging(ctx, &s3.GetObjectTaggingInput{ + Bucket: &bucket, + Key: &key, + }) + cancel() + if err != nil { + return err + } + if len(tagging.TagSet) != 0 { + return fmt.Errorf("expected no tags, instead got %v", tagging.TagSet) + } + + return nil + }) +} + func PostObject_invalid_object_names(s *S3Conf) error { testName := "PostObject_invalid_object_names" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 3ca71dd5..426970f1 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1751,6 +1751,7 @@ func TestS3IAMAccessControl(ts *TestState) { ts.Run(S3IAMAccessControl_identity_policy_resource_scoping) ts.Run(S3IAMAccessControl_identity_policy_bucket_vs_object_arn) ts.Run(S3IAMAccessControl_post_object_identity_policy_resource_scoping) + ts.Run(S3IAMAccessControl_post_object_tagging_identity_policy) ts.Run(S3IAMAccessControl_identity_policy_not_action_and_not_resource) ts.Run(S3IAMAccessControl_identity_policy_explicit_deny_wins) ts.Run(S3IAMAccessControl_multiple_inline_policies_combine) @@ -2131,6 +2132,9 @@ func TestPostObject(ts *TestState) { ts.Run(PostObject_access_denied) ts.Run(PostObject_bucket_policy_object_resource) ts.Run(PostObject_bucket_policy_explicit_deny) + ts.Run(PostObject_tagging_requires_put_object_tagging) + ts.Run(PostObject_tagging_bucket_policy_explicit_deny) + ts.Run(PostObject_empty_tag_set_requires_only_put_object) ts.Run(PostObject_invalid_object_names) ts.Run(PostObject_policy_access_control) ts.Run(PostObject_policy_expired) @@ -2262,6 +2266,7 @@ func GetIntTests() IntTests { "S3IAMAccessControl_identity_policy_resource_scoping": S3IAMAccessControl_identity_policy_resource_scoping, "S3IAMAccessControl_identity_policy_bucket_vs_object_arn": S3IAMAccessControl_identity_policy_bucket_vs_object_arn, "S3IAMAccessControl_post_object_identity_policy_resource_scoping": S3IAMAccessControl_post_object_identity_policy_resource_scoping, + "S3IAMAccessControl_post_object_tagging_identity_policy": S3IAMAccessControl_post_object_tagging_identity_policy, "S3IAMAccessControl_identity_policy_not_action_and_not_resource": S3IAMAccessControl_identity_policy_not_action_and_not_resource, "S3IAMAccessControl_identity_policy_explicit_deny_wins": S3IAMAccessControl_identity_policy_explicit_deny_wins, "S3IAMAccessControl_multiple_inline_policies_combine": S3IAMAccessControl_multiple_inline_policies_combine, @@ -3699,6 +3704,9 @@ func GetIntTests() IntTests { "PostObject_access_denied": PostObject_access_denied, "PostObject_bucket_policy_object_resource": PostObject_bucket_policy_object_resource, "PostObject_bucket_policy_explicit_deny": PostObject_bucket_policy_explicit_deny, + "PostObject_tagging_requires_put_object_tagging": PostObject_tagging_requires_put_object_tagging, + "PostObject_tagging_bucket_policy_explicit_deny": PostObject_tagging_bucket_policy_explicit_deny, + "PostObject_empty_tag_set_requires_only_put_object": PostObject_empty_tag_set_requires_only_put_object, "PostObject_invalid_object_names": PostObject_invalid_object_names, "PostObject_policy_access_control": PostObject_policy_access_control, "PostObject_policy_expired": PostObject_policy_expired, diff --git a/tests/integration/s3_iam_access_control.go b/tests/integration/s3_iam_access_control.go index 9ca1232f..050e23b5 100644 --- a/tests/integration/s3_iam_access_control.go +++ b/tests/integration/s3_iam_access_control.go @@ -284,6 +284,83 @@ func S3IAMAccessControl_post_object_identity_policy_resource_scoping(s *S3Conf) }) } +// S3IAMAccessControl_post_object_tagging_identity_policy verifies a POST +// upload that tags its object needs s3:PutObjectTagging on the object in +// addition to s3:PutObject, as PutObject does. With only s3:PutObject +// granted, the tagged upload is denied naming s3:PutObjectTagging, and an +// explicit Deny on s3:PutObjectTagging refuses it even under s3:*. The +// untagged upload succeeds in both cases. +func S3IAMAccessControl_post_object_tagging_identity_policy(s *S3Conf) error { + testName := "S3IAMAccessControl_post_object_tagging_identity_policy" + return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error { + cases := []struct { + name string + policy string + want func(principal, resourceArn string) s3err.S3Error + }{ + { + name: "s3:PutObject only", + policy: policyDoc(accessStatement{ + Effect: "Allow", Action: actS3PutObject, Resource: objectsArn(bucket), + }), + want: func(principal, resourceArn string) s3err.S3Error { + return wantImplicitDeny(principal, actS3PutObjectTagging, resourceArn) + }, + }, + { + name: "s3:PutObjectTagging explicitly denied", + policy: policyDoc( + accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}, + accessStatement{Effect: "Deny", Action: actS3PutObjectTagging, Resource: objectsArn(bucket)}, + ), + want: func(principal, resourceArn string) s3err.S3Error { + return wantExplicitIdentityDeny(principal, actS3PutObjectTagging, resourceArn) + }, + }, + } + for _, tc := range cases { + if err := func() error { + user, cleanup, err := newS3IAMUser(root, s, map[string]string{"p": tc.policy}) + if err != nil { + return err + } + defer cleanup() + + cfg := PostRequestConfig{ + bucket: bucket, + key: "obj", + s3Conf: &user.conf, + fileContent: []byte("data"), + } + + resp, err := sendPostObject(cfg) + if err != nil { + return err + } + if err := checkPostObjectSuccess(resp); err != nil { + return fmt.Errorf("expected the untagged POST to be allowed: %w", err) + } + + taggingXML := `envtest` + cfg.policyConditions = []any{ + []any{"eq", "$tagging", taggingXML}, + } + cfg.extraFields = map[string]string{ + "tagging": taggingXML, + } + resp, err = sendPostObject(cfg) + if err != nil { + return err + } + return checkHTTPResponseApiErr(resp, tc.want(user.arn, objectArn(bucket, "obj"))) + }(); err != nil { + return fmt.Errorf("%s: %w", tc.name, err) + } + } + return nil + }) +} + // S3IAMAccessControl_identity_policy_not_action_and_not_resource verifies // NotAction and NotResource grant everything *except* what they name. func S3IAMAccessControl_identity_policy_not_action_and_not_resource(s *S3Conf) error { diff --git a/tests/integration/s3_iam_utils.go b/tests/integration/s3_iam_utils.go index 645caefb..535f239f 100644 --- a/tests/integration/s3_iam_utils.go +++ b/tests/integration/s3_iam_utils.go @@ -40,6 +40,7 @@ import ( const ( actS3GetObject = "s3:GetObject" actS3PutObject = "s3:PutObject" + actS3PutObjectTagging = "s3:PutObjectTagging" actS3DeleteObject = "s3:DeleteObject" actS3DeleteObjectVersion = "s3:DeleteObjectVersion" actS3ListBucket = "s3:ListBucket"