mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 06:20:48 +02:00
* s3: keep '\' a literal object-key character when AllowBackslashInKey is set NormalizeObjectKey rewrites every S3 object key: '\' becomes '/' and duplicate slashes collapse. On AWS S3 both are ordinary key characters, so keys that are distinct there alias one object here — writing probe/back/slash.txt silently overwrites probe/back\slash.txt. The filer only treats '/' as a path separator, so a verbatim '\' stays inside one entry name. Gate the backslash rewrite and the matching '\' handling in IsValidObjectKey behind a process-wide mode the S3 server sets from the new S3ApiServerOption. Duplicate-slash collapsing stays: an entry name cannot contain an empty segment. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * command: add -s3.allowBackslashInKey flag for AWS-compatible key handling Registers the flag on all four S3 entry points (s3, server -s3, filer -s3, mini) and passes it through S3Options into S3ApiServerOption. Default off, so existing normalization is unchanged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: correct IsValidObjectKey/NormalizeObjectKey docs under allowBackslashInKey Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: normalize DeleteObjects body keys the same as path keys DeleteObjects keys arrive in the XML body, so they bypassed the normalization applied to route-var keys. The lock, the authorization checks, the table-bucket layout check and the filer delete itself all addressed the raw key while the conditional check resolved the normalized one, so a key spelled with '\' or '//' could be reported deleted while the stored entry survived. Echo the client-sent key in the result while operating on the normalized form. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: report a normalized-empty DeleteObjects key as a delete no-op A multi-delete key of only slashes normalizes to the empty string. Left to reach the filer delete, the empty name resolves to the bucket entry itself, so an unauthorized caller could remove a bucket through the body-key path. Such a key can never name a stored entry, so like a missing key the delete is an idempotent no-op reported before any permission check or filer call. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: drop unused AllowBackslashInObjectKey getter * s3: answer conditional delete of a normalized-empty key with NoSuchKey * s3: normalize the probed key in authorizeAnonymousBucketList The masked-miss probe read the raw mux var, so a spelling like folder// or folder\ was authorized against a key that can never exist while the request itself targets the normalized folder/. Probe the normalized key so the answer matches what the read path authorizes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: cover anonymous authz of a literal-backslash key under allowBackslashInKey A public grant on the slash key must not authorize an anonymous read of the distinct backslash object when the flag keeps '\' literal; grant the backslash key's own ACL and it reads back. Default mode still folds the spelling onto the covered slash key. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3: publish the key mode only after the S3 server is constructed A failed NewS3ApiServerWithStore could previously flip the process-wide key mode out from under an already-running gateway. * s3: normalize the copy-source key before validation and authorization pathToBucketObjectAndVersion returned the raw header spelling, so an equivalent spelling (a//b, a\b) was authorized and compared against the normalized destination while resolveCopySourceEntry looked up the normalized key — a deny on the canonical key could be bypassed and self-copy detection missed. Normalize once at parse so the CopyObject and UploadPartCopy paths agree. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
184 lines
8.4 KiB
Go
184 lines
8.4 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
)
|
|
|
|
type anonymousObjectReadContextKey struct{}
|
|
|
|
// isAnonymousObjectRead identifies requests deferred by AuthWithPublicRead to
|
|
// the selected entry's authorization check. Clients cannot set this marker.
|
|
func isAnonymousObjectRead(r *http.Request) bool {
|
|
marked, _ := r.Context().Value(anonymousObjectReadContextKey{}).(bool)
|
|
return marked
|
|
}
|
|
|
|
// deferAnonymousObjectRead only grants entry-dependent authorization to raw
|
|
// GET/HEAD reads. Subresources, writes and requests carrying credentials keep
|
|
// their normal authentication path; no-auth development mode is unchanged.
|
|
func (s3a *S3ApiServer) deferAnonymousObjectRead(r *http.Request, action Action, bucket, object string) (*http.Request, bool) {
|
|
if s3a.iam == nil || !s3a.iam.isEnabled() || action != s3_constants.ACTION_READ ||
|
|
getRequestAuthType(r) != authTypeAnonymous || object == "" || object == "/" ||
|
|
(r.Method != http.MethodGet && r.Method != http.MethodHead) || isSOSAPIObject(strings.TrimPrefix(object, "/")) {
|
|
return r, false
|
|
}
|
|
resolved := ResolveS3Action(r, string(action), bucket, object)
|
|
if r.URL.Query().Has("uploads") || (resolved != s3_constants.S3_ACTION_GET_OBJECT && resolved != s3_constants.S3_ACTION_GET_OBJECT_VERSION) {
|
|
return r, false
|
|
}
|
|
// A session token without a signature names no session; drop it so it
|
|
// cannot influence identity or policy evaluation downstream.
|
|
r.Header.Del("X-Amz-Security-Token")
|
|
if q := r.URL.Query(); q.Has("X-Amz-Security-Token") {
|
|
q.Del("X-Amz-Security-Token")
|
|
r.URL.RawQuery = q.Encode()
|
|
}
|
|
|
|
// Reuse authentication's internal-header sanitization. A public ACL does
|
|
// not require an anonymous identity; a configured one still contributes
|
|
// its permissions and explicit identity-policy denies.
|
|
identity, _, _ := s3a.iam.authenticateRequestInternal(r)
|
|
ctx := recordIdentityInContext(r, identity)
|
|
return r.WithContext(context.WithValue(ctx, anonymousObjectReadContextKey{}, true)), true
|
|
}
|
|
|
|
// authorizeAnonymousObjectRead evaluates policies and ACLs against the same
|
|
// entry used for the response, including conditional and versioned reads.
|
|
// Explicit policy denies take precedence over every public-access grant.
|
|
func (s3a *S3ApiServer) authorizeAnonymousObjectRead(r *http.Request, bucket, object string, extended map[string][]byte) s3err.ErrorCode {
|
|
// A version's physical filer path is not another S3 key: accepting it would
|
|
// bypass GetObjectVersion policies on the logical key. Ordinary user keys
|
|
// containing ".versions" without internal version metadata are unaffected.
|
|
key := s3_constants.NormalizeObjectKey(object)
|
|
version := string(extended[s3_constants.ExtVersionIdKey])
|
|
if version != "" && strings.HasSuffix(path.Dir(key), s3_constants.VersionsFolder) && path.Base(key) == s3a.getVersionFileName(version) {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
if string(extended[s3_constants.ExtDeleteMarkerKey]) == "true" {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
// Loading config also synchronizes a cold bucket policy into the engine.
|
|
config, code := s3a.getBucketConfig(bucket)
|
|
if code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
|
|
code, policyAllowed := s3a.checkPolicyWithEntry(r, bucket, object, string(s3_constants.ACTION_READ), buildPrincipalARN(identity, r), extended)
|
|
if code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
if s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(r, identity, s3_constants.ACTION_READ, bucket, object) {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
if policyAllowed {
|
|
return s3err.ErrNone
|
|
}
|
|
if identity != nil && s3a.iam.VerifyActionPermission(r, identity, s3_constants.ACTION_READ, bucket, object) == s3err.ErrNone {
|
|
return s3err.ErrNone
|
|
}
|
|
// Match the upload/ACL path: legacy buckets without recorded ownership
|
|
// controls still accept ACLs. An enforced control explicitly disables them.
|
|
if config.Ownership == "" || s3_constants.EffectiveOwnership(config.Ownership) != s3_constants.OwnershipBucketOwnerEnforced {
|
|
if data, exists := extended[s3_constants.ExtAmzAclKey]; exists {
|
|
// An explicitly stored ACL replaces the legacy bucket-ACL fallback.
|
|
// Empty, malformed and non-public ACLs must not grant anonymous read.
|
|
var grants []*s3.Grant
|
|
if json.Unmarshal(data, &grants) == nil {
|
|
for _, grant := range grants {
|
|
if grant != nil && grant.Grantee != nil && grant.Grantee.Type != nil && *grant.Grantee.Type == "Group" &&
|
|
grant.Grantee.URI != nil && *grant.Grantee.URI == s3_constants.GranteeGroupAllUsers && grant.Permission != nil &&
|
|
(*grant.Permission == s3_constants.PermissionRead || *grant.Permission == s3_constants.PermissionFullControl) {
|
|
return s3err.ErrNone
|
|
}
|
|
}
|
|
}
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
}
|
|
// Preserve SeaweedFS's bucket-public-read behavior for legacy objects that
|
|
// have no stored object ACL; BucketOwnerEnforced ignores object grants.
|
|
if config.IsPublicRead {
|
|
return s3err.ErrNone
|
|
}
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
|
|
// authorizeAnonymousBucketList reports whether an anonymous caller may learn
|
|
// which keys exist in the bucket: bucket listing via policy or identity, or a
|
|
// public-read bucket. Anything else masks the miss as AccessDenied.
|
|
func (s3a *S3ApiServer) authorizeAnonymousBucketList(r *http.Request, bucket string) s3err.ErrorCode {
|
|
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
|
|
object := s3_constants.NormalizeObjectKey(mux.Vars(r)["object"])
|
|
// The caller's query parameters would otherwise retarget these checks
|
|
// (?policy resolves to GetBucketPolicy) or satisfy a prefix condition on an
|
|
// unrelated subtree, so each runs on a request with a fixed shape: a
|
|
// ListBucket scoped to the probed key, and a plain read of the key itself.
|
|
// A caller that could learn the key's existence either way — by listing or
|
|
// by getting it — gets the real miss.
|
|
listReq := r.Clone(r.Context())
|
|
listReq.URL = &url.URL{Path: "/" + bucket, RawQuery: url.Values{"prefix": {object}}.Encode()}
|
|
// versionId must survive: a read of a specific version resolves to
|
|
// s3:GetObjectVersion, and dropping it would let a GetObject grant answer
|
|
// for versions the caller may not read.
|
|
readQuery := url.Values{}
|
|
if v := r.URL.Query().Get("versionId"); v != "" {
|
|
readQuery.Set("versionId", v)
|
|
}
|
|
readReq := r.Clone(r.Context())
|
|
readReq.URL = &url.URL{Path: "/" + bucket + "/" + object, RawQuery: readQuery.Encode()}
|
|
|
|
principal := buildPrincipalARN(identity, r)
|
|
policyAllowed := false
|
|
code, allowed := s3a.checkPolicyWithEntry(listReq, bucket, "", string(s3_constants.ACTION_LIST), principal, nil)
|
|
if code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
policyAllowed = allowed
|
|
if !policyAllowed {
|
|
code, allowed = s3a.checkPolicyWithEntry(readReq, bucket, object, string(s3_constants.ACTION_READ), principal, nil)
|
|
if code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
policyAllowed = allowed
|
|
}
|
|
if s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(listReq, identity, s3_constants.ACTION_LIST, bucket, "") ||
|
|
s3a.iam.isActionExplicitlyDeniedByApplicablePolicies(readReq, identity, s3_constants.ACTION_READ, bucket, object) {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
if policyAllowed {
|
|
return s3err.ErrNone
|
|
}
|
|
if identity != nil &&
|
|
(s3a.iam.VerifyActionPermission(listReq, identity, s3_constants.ACTION_LIST, bucket, "") == s3err.ErrNone ||
|
|
s3a.iam.VerifyActionPermission(readReq, identity, s3_constants.ACTION_READ, bucket, object) == s3err.ErrNone) {
|
|
return s3err.ErrNone
|
|
}
|
|
if config, code := s3a.getBucketConfig(bucket); code == s3err.ErrNone && config.IsPublicRead {
|
|
return s3err.ErrNone
|
|
}
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
|
|
// writeAnonymousAwareNotFound keeps missing-bucket and missing-key misses
|
|
// indistinguishable from denied reads for anonymous callers: a 404 only
|
|
// reaches the wire when the bucket is anonymously listable. The denial is
|
|
// refined like Auth's, so a malformed bucket name answers InvalidBucketName.
|
|
func (s3a *S3ApiServer) writeAnonymousAwareNotFound(w http.ResponseWriter, r *http.Request, bucket string, code s3err.ErrorCode) {
|
|
if isAnonymousObjectRead(r) && s3a.authorizeAnonymousBucketList(r, bucket) != s3err.ErrNone {
|
|
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
|
|
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, s3err.ErrAccessDenied))
|
|
return
|
|
}
|
|
s3err.WriteErrorResponse(w, r, code)
|
|
}
|