mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-08 07:17:48 +02:00
s3api: persist ACLs on PutObject uploads (#11592)
* s3api: persist ACLs on PutObject uploads Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3api: fix PutObject ACL edge cases found in review - Only enforce BucketOwnerEnforced when explicitly configured; buckets without a stored ownership control keep accepting upload ACLs - Ignore ACL query parameters on SigV2 requests, which do not sign them - Mirror signed-query ACL values into headers after authentication so grant parsing and resolveFileMode agree on presigned uploads - Validate only caller-supplied grantees against the account registry; default grants now work for accounts outside the local registry - Reject unknown grantee keys and accept comma-separated grantee lists without spaces in ParseCustomAclHeader - Guard against identities without an account * s3api: harden upload ACL parsing and authorization Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3api: evaluate upload ACL grantees individually in policies A comma-joined grant header or a signed query parameter reached policy conditions as one value, so a deny on a later grantee did not fire. Split grant headers into per-grantee values for policy evaluation and share the grantee pair parser with ParseCustomAclHeader. * s3api: keep raw grant header values visible to policy conditions Exact-match conditions written against the signed header value stopped matching once grantees were split for evaluation. Preserve the original wire values alongside the per-grantee values so deny policies fire on either granularity. * s3api: evaluate upload ACL grants as one canonical list in policies Conditions on s3:x-amz-grant-* now see a single comma-separated canonical grant list identical for a single line, repeated header lines, or a signed query parameter. This keeps StringEquals allows and exact-list or allowlist (StringNotEquals) denies accurate regardless of wire encoding. * s3api: preserve upload ACL denies and align policy checks Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> * s3api: retain upload owner grants and literal policy values Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> --------- Signed-off-by: zhaoyuchen <43179751+zhao-yc@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
1 parent
9d1c24d80d
commit
483dd4b12e
15 files changed
+1266
-48
No files matched your search
@@ -0,0 +1,265 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
// putObjectACLContextKey carries validated ACL metadata to every PutObject write
|
||||
// path without exposing an internal header that a client could forge.
|
||||
type putObjectACLContextKey struct{}
|
||||
|
||||
type putObjectACLMetadata struct {
|
||||
extended map[string][]byte
|
||||
canned string
|
||||
}
|
||||
|
||||
// putObjectACLValue ignores unsigned V2 query ACLs and rejects ambiguity.
|
||||
// Headers stay untouched because authentication verifies the original request.
|
||||
func putObjectACLValue(r *http.Request, query url.Values, header string) (string, s3err.ErrorCode) {
|
||||
// Preserve SigV2's header-only ACL behavior: arbitrary query parameters
|
||||
// are not in its canonical resource and must have no effect on grants.
|
||||
switch getRequestAuthType(r) {
|
||||
case authTypeSignedV2, authTypePresignedV2:
|
||||
query = nil
|
||||
}
|
||||
var queryValues []string
|
||||
queryPresent := false
|
||||
for key, values := range query {
|
||||
if strings.EqualFold(key, header) {
|
||||
queryPresent = true
|
||||
queryValues = append(queryValues, values...)
|
||||
}
|
||||
}
|
||||
if queryPresent {
|
||||
if len(queryValues) != 1 {
|
||||
// V4 sorts duplicate values when signing. Choosing the first value
|
||||
// would let reordering change the effective ACL without resigning.
|
||||
return "", s3err.ErrInvalidRequest
|
||||
}
|
||||
}
|
||||
values := r.Header.Values(header)
|
||||
if header == s3_constants.AmzCannedAcl && len(values) > 1 {
|
||||
return "", s3err.ErrInvalidRequest
|
||||
}
|
||||
value := strings.Join(values, ",")
|
||||
if queryPresent {
|
||||
if len(values) > 0 && value != queryValues[0] {
|
||||
return "", s3err.ErrInvalidRequest
|
||||
}
|
||||
value = queryValues[0]
|
||||
}
|
||||
return value, s3err.ErrNone
|
||||
}
|
||||
|
||||
// putObjectACLPolicyRequest exposes effective PUT ACLs to policy conditions only
|
||||
// after authentication. Other operations keep their original request semantics.
|
||||
func putObjectACLPolicyRequest(r *http.Request, action Action, bucket, object string) (*http.Request, s3err.ErrorCode) {
|
||||
// Copy routes match any repeated header value, so checking only the first line can misclassify a copy as a regular upload.
|
||||
copyRequest := false
|
||||
for _, copySource := range r.Header.Values("X-Amz-Copy-Source") {
|
||||
if strings.Contains(copySource, "/") || strings.Contains(strings.ToLower(copySource), "%2f") {
|
||||
copyRequest = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (action != s3_constants.ACTION_WRITE && action != s3_constants.ACTION_WRITE_ACP) ||
|
||||
r.Method != http.MethodPut || object == "" || object == "/" ||
|
||||
copyRequest ||
|
||||
ResolveS3Action(r, string(s3_constants.ACTION_WRITE), bucket, object) != s3_constants.S3_ACTION_PUT_OBJECT {
|
||||
return r, s3err.ErrNone
|
||||
}
|
||||
// Rechecks reuse the normalized internal request, preserving signed original values without false query conflicts.
|
||||
if len(policy_engine.OriginalGrantConditionsFromRequest(r)) != 0 {
|
||||
return r, s3err.ErrNone
|
||||
}
|
||||
policyRequest := r.Clone(r.Context())
|
||||
query := parseRequestQuery(r)
|
||||
originalGrants := make(map[string][]string)
|
||||
for _, header := range []string{s3_constants.AmzCannedAcl, s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
|
||||
value, code := putObjectACLValue(r, query, header)
|
||||
if code != s3err.ErrNone {
|
||||
return r, code
|
||||
}
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if header == s3_constants.AmzCannedAcl {
|
||||
policyRequest.Header.Set(header, value)
|
||||
continue
|
||||
}
|
||||
// Preserve only the complete effective list for original-string denies, not individual header lines as separate lists.
|
||||
originalGrants["s3:"+strings.ToLower(header)] = []string{value}
|
||||
// Policy conditions see the canonical grant list: one comma-separated
|
||||
// value covering every persisted grantee, identical for a single line,
|
||||
// repeated lines, or a signed query parameter. Sneaking an extra grantee
|
||||
// past a StringEquals allow or a StringNotEquals allowlist deny requires
|
||||
// changing this value, which a signed request cannot do.
|
||||
pairs, pairCode := parseAclGranteePairs(value)
|
||||
if pairCode != s3err.ErrNone {
|
||||
return r, pairCode
|
||||
}
|
||||
var tokens []string
|
||||
for _, pair := range pairs {
|
||||
// Grant conditions use JSON quoting without HTML escaping, so valid
|
||||
// literal characters in an account or email still match the policy.
|
||||
var encoded strings.Builder
|
||||
encoder := json.NewEncoder(&encoded)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(pair[1]); err != nil {
|
||||
return r, s3err.ErrInvalidRequest
|
||||
}
|
||||
tokens = append(tokens, pair[0]+"="+strings.TrimSuffix(encoded.String(), "\n"))
|
||||
}
|
||||
policyRequest.Header.Set(header, strings.Join(tokens, ","))
|
||||
}
|
||||
if len(originalGrants) != 0 {
|
||||
policyRequest = policy_engine.WithOriginalGrantConditions(policyRequest, originalGrants)
|
||||
}
|
||||
return policyRequest, s3err.ErrNone
|
||||
}
|
||||
|
||||
// preparePutObjectACL validates and authorizes ACLs before the upload body is
|
||||
// consumed. The resulting metadata is committed in the same entry as the object.
|
||||
func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*http.Request, s3err.ErrorCode) {
|
||||
metadata, code := s3a.getBucketConfig(bucket)
|
||||
if code != s3err.ErrNone {
|
||||
return r, code
|
||||
}
|
||||
if metadata == nil || s3a.iam == nil {
|
||||
return r, s3err.ErrInternalError
|
||||
}
|
||||
|
||||
// Presigners can hoist ACL headers into the signed query string. Normalize a
|
||||
// separate request for parsing, preserving the original for signature checks.
|
||||
aclRequest := r.Clone(r.Context())
|
||||
query := parseRequestQuery(r)
|
||||
custom := false
|
||||
for _, header := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
|
||||
value, code := putObjectACLValue(r, query, header)
|
||||
if code != s3err.ErrNone {
|
||||
return r, code
|
||||
}
|
||||
if value != "" {
|
||||
custom = true
|
||||
aclRequest.Header.Set(header, value)
|
||||
}
|
||||
}
|
||||
canned, code := putObjectACLValue(r, query, s3_constants.AmzCannedAcl)
|
||||
if code != s3err.ErrNone {
|
||||
return r, code
|
||||
}
|
||||
aclRequest.Header.Set(s3_constants.AmzCannedAcl, canned)
|
||||
explicit := canned != "" || custom
|
||||
accountID := r.Header.Get(s3_constants.AmzAccountId)
|
||||
if !s3a.iam.isEnabled() {
|
||||
accountID = AccountAdmin.Id
|
||||
} else if explicit {
|
||||
// Setting an ACL during PutObject also requires s3:PutObjectAcl. Use the
|
||||
// unified authorization path so bucket-policy allows and explicit denies
|
||||
// retain the same semantics as standalone ACL requests.
|
||||
identity, authCode := s3a.iam.authRequest(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
|
||||
if authCode != s3err.ErrNone {
|
||||
return r, authCode
|
||||
}
|
||||
if identity == nil || identity.Account == nil {
|
||||
return r, s3err.ErrAccessDenied
|
||||
}
|
||||
accountID = identity.Account.Id
|
||||
}
|
||||
if explicit && !s3a.iam.isEnabled() {
|
||||
_, object := s3_constants.GetBucketAndObject(r)
|
||||
policyRequest, policyCode := putObjectACLPolicyRequest(r, s3_constants.ACTION_WRITE, bucket, object)
|
||||
if policyCode != s3err.ErrNone {
|
||||
return r, policyCode
|
||||
}
|
||||
for _, action := range []Action{s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP} {
|
||||
if policyCode, _ := s3a.checkPolicyWithEntry(policyRequest, bucket, object, string(action), "", nil); policyCode != s3err.ErrNone {
|
||||
return r, policyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
if accountID == "" {
|
||||
return r, s3err.ErrAccessDenied
|
||||
}
|
||||
if canned != "" && custom {
|
||||
return r, s3err.ErrInvalidRequest
|
||||
}
|
||||
|
||||
bucketOwner := metadata.Owner
|
||||
if bucketOwner == "" {
|
||||
// Buckets created outside S3 can have no recorded owner, matching the
|
||||
// bucket registry's existing admin fallback for these entries.
|
||||
bucketOwner = AccountAdmin.Id
|
||||
}
|
||||
ownership := s3_constants.EffectiveOwnership(metadata.Ownership)
|
||||
if ownership == s3_constants.OwnershipBucketOwnerEnforced {
|
||||
if metadata.Ownership == s3_constants.OwnershipBucketOwnerEnforced {
|
||||
// Keep legacy buckets without recorded ownership controls accepting
|
||||
// ACLs; only an explicitly configured enforced control disables them.
|
||||
if custom || (canned != "" && canned != s3_constants.CannedAclBucketOwnerFullControl) {
|
||||
return r, s3err.ErrAccessControlListNotSupported
|
||||
}
|
||||
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
|
||||
}
|
||||
accountID = bucketOwner
|
||||
}
|
||||
if aclRequest.Header.Get(s3_constants.AmzCannedAcl) == "" && !custom {
|
||||
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
|
||||
}
|
||||
// Canned grants contain only authenticated writer and recorded bucket-owner
|
||||
// IDs. Dynamic IAM/JWT accounts need not exist in the static account directory.
|
||||
// Client-supplied custom grantees must still pass directory validation.
|
||||
owner, grants, code := ParseAclHeaders(aclRequest, ownership, bucketOwner, accountID, false)
|
||||
if code == s3err.ErrNone && custom {
|
||||
grants, code = ValidateAndTransferGrants(s3a.iam, grants)
|
||||
}
|
||||
if code != s3err.ErrNone {
|
||||
return r, code
|
||||
}
|
||||
if custom {
|
||||
// Custom upload grants supplement the owner's default full control.
|
||||
// Check after email resolution to avoid duplicating an explicit owner
|
||||
// grant; the authenticated owner need not be in the static directory.
|
||||
ownerFullControl := false
|
||||
for _, grant := range grants {
|
||||
if grant.Grantee != nil && grant.Grantee.Type != nil &&
|
||||
*grant.Grantee.Type == s3_constants.GrantTypeCanonicalUser &&
|
||||
grant.Grantee.ID != nil && *grant.Grantee.ID == owner &&
|
||||
grant.Permission != nil && *grant.Permission == s3_constants.PermissionFullControl {
|
||||
ownerFullControl = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ownerFullControl {
|
||||
grants = append(grants, &s3.Grant{
|
||||
Grantee: &s3.Grantee{Type: &s3_constants.GrantTypeCanonicalUser, ID: &owner},
|
||||
Permission: &s3_constants.PermissionFullControl,
|
||||
})
|
||||
}
|
||||
}
|
||||
entry := &filer_pb.Entry{}
|
||||
if code = AssembleEntryWithAcp(entry, owner, grants); code != s3err.ErrNone {
|
||||
return r, code
|
||||
}
|
||||
prepared := putObjectACLMetadata{extended: entry.Extended, canned: canned}
|
||||
return r.WithContext(context.WithValue(r.Context(), putObjectACLContextKey{}, prepared)), s3err.ErrNone
|
||||
}
|
||||
|
||||
// applyPutObjectACL adds prevalidated ownership and grants before CreateEntry.
|
||||
// Multipart parts and POST form uploads do not carry this PutObject context.
|
||||
func applyPutObjectACL(r *http.Request, entry *filer_pb.Entry) {
|
||||
metadata, _ := r.Context().Value(putObjectACLContextKey{}).(putObjectACLMetadata)
|
||||
for key, value := range metadata.extended {
|
||||
entry.Extended[key] = value
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user