s3: require their own actions to change a bucket's CORS or versioning (#12433)

* s3: require their own actions to change a bucket's CORS or versioning

PUT/DELETE ?cors and PUT ?versioning were authorized as Write, so an
identity granted only object writes on a bucket could replace its CORS
rules or suspend versioning. They now need PutBucketCors,
DeleteBucketCors and PutBucketVersioning (or Admin), and inline policies
granting s3:PutBucketCors, s3:DeleteBucketCors or s3:PutBucketVersioning
map to those actions instead of Write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: test that object Write cannot change bucket CORS or versioning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* admin: offer the bucket CORS and versioning permissions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3: test each bucket config action with its own identity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-06 09:12:00 -08:00
1 parent 3a579ece3b
commit 3e64326f2b
7 files changed
+109 -7

No files matched your search

+77
View File
@@ -588,3 +588,80 @@ func TestRouting_BucketLifecycleWritesNeedAdmin(t *testing.T) {
}
}
}
func TestRouting_BucketCorsAndVersioningWritesNeedTheirActions(t *testing.T) {
const bucket = "config-bucket"
configFile := filepath.Join(t.TempDir(), "s3.json")
require.NoError(t, os.WriteFile(configFile, []byte(`{
"identities": [
{"name":"writer","credentials":[{"accessKey":"WRITERKEY","secretKey":"writersecret"}],"actions":["Read:`+bucket+`","Write:`+bucket+`","List:`+bucket+`","Tagging:`+bucket+`"]},
{"name":"bucketadmin","credentials":[{"accessKey":"BUCKETADMINKEY","secretKey":"bucketadminsecret"}],"actions":["Admin:`+bucket+`"]},
{"name":"corsputter","credentials":[{"accessKey":"CORSPUTKEY","secretKey":"corsputsecret"}],"actions":["PutBucketCors:`+bucket+`"]},
{"name":"corsdeleter","credentials":[{"accessKey":"CORSDELKEY","secretKey":"corsdelsecret"}],"actions":["DeleteBucketCors:`+bucket+`"]},
{"name":"versioner","credentials":[{"accessKey":"VERSIONERKEY","secretKey":"versionersecret"}],"actions":["PutBucketVersioning:`+bucket+`"]},
{"name":"policyuser","credentials":[{"accessKey":"POLICYUSERKEY","secretKey":"policyusersecret"}],"policyNames":["ConfigPolicy"]}
],
"policies": [
{"name":"ConfigPolicy","content":"{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:PutBucketCors\",\"s3:DeleteBucketCors\",\"s3:PutBucketVersioning\"],\"Resource\":[\"arn:aws:s3:::`+bucket+`\"]}]}"}
]
}`), 0600))
s3a := setupRoutingTestServer(t)
s3a.iam = NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: configFile}, nil, "memory")
require.True(t, s3a.iam.isEnabled())
s3a.cb = &CircuitBreaker{s3a: s3a}
s3a.bucketConfigCache = NewBucketConfigCache(time.Minute)
s3a.bucketConfigCache.SetNegativeCache(bucket)
router := mux.NewRouter()
s3a.registerRouter(router)
send := func(accessKey, secretKey, method, query, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "http://localhost:8333/"+bucket+"?"+query, strings.NewReader(body))
creds := credentials.NewStaticCredentials(accessKey, secretKey, "")
_, err := v4.NewSigner(creds).Sign(req, strings.NewReader(body), "s3", "us-east-1", time.Now())
require.NoError(t, err)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
const corsXML = `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`
const versioningXML = `<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>`
singleActionKeys := map[string]string{"CORSPUTKEY": "corsputsecret", "CORSDELKEY": "corsdelsecret", "VERSIONERKEY": "versionersecret"}
for _, tc := range []struct {
method, query, body, allowedKey string
}{
{http.MethodPut, "cors", corsXML, "CORSPUTKEY"},
{http.MethodDelete, "cors", "", "CORSDELKEY"},
{http.MethodPut, "versioning", versioningXML, "VERSIONERKEY"},
} {
denied := [][2]string{{"WRITERKEY", "writersecret"}}
allowed := [][2]string{{"BUCKETADMINKEY", "bucketadminsecret"}, {"POLICYUSERKEY", "policyusersecret"}}
for key, secret := range singleActionKeys {
if key == tc.allowedKey {
allowed = append(allowed, [2]string{key, secret})
} else {
denied = append(denied, [2]string{key, secret})
}
}
for _, key := range denied {
rr := send(key[0], key[1], tc.method, tc.query, tc.body)
assert.Equal(t, http.StatusForbidden, rr.Code, "%s %s ?%s", key[0], tc.method, tc.query)
assert.Contains(t, rr.Body.String(), "AccessDenied", "%s %s ?%s", key[0], tc.method, tc.query)
}
for _, key := range allowed {
rr := send(key[0], key[1], tc.method, tc.query, tc.body)
assert.Equal(t, http.StatusNotFound, rr.Code, "%s %s ?%s", key[0], tc.method, tc.query)
assert.Contains(t, rr.Body.String(), "NoSuchBucket", "%s %s ?%s", key[0], tc.method, tc.query)
}
}
for policyAction, identityAction := range map[string]string{
"s3:PutBucketCors": s3_constants.ACTION_PUT_BUCKET_CORS,
"s3:DeleteBucketCors": s3_constants.ACTION_DELETE_BUCKET_CORS,
"s3:PutBucketVersioning": s3_constants.ACTION_PUT_BUCKET_VERSIONING,
} {
assert.Equal(t, identityAction, iamMapToStatementAction(policyAction), policyAction)
assert.Equal(t, policyAction, "s3:"+iamMapToIdentitiesAction(identityAction), identityAction)
}
}