Files
seaweedfs/weed/s3api/policy_engine/bucket_policy.go
T
Chris LuandGitHub 79994b69af s3: fail closed on unsupported bucket-policy condition operators (#11283)
* s3: support StringEqualsIgnoreCase and related condition operators

The S3 bucket-policy condition engine rejected StringEqualsIgnoreCase
(and StringNotEqualsIgnoreCase, StringLikeIgnoreCase,
StringNotLikeIgnoreCase), which AWS and the IAM policy engine both
accept. Add evaluators and register them in GetConditionEvaluator so
valid policies using these operators evaluate correctly instead of
being skipped.

* s3: reject bucket policies with unsupported condition operators

validateStatement did not check Condition operators, so a policy with
an unknown operator (e.g. a typo or unsupported key) was accepted at
upload time and only surfaced at evaluation, where it was silently
skipped. Reuse GetConditionEvaluator to reject unknown operators when
a policy is parsed or stored, failing closed at the entry point
instead of relying on evaluation-time handling.

* s3: fail closed on unsupported condition operators at evaluation

EvaluateConditions skipped statements whose condition operator was
unsupported, logging a warning and continuing. With no remaining
conditions to fail, the function returned true, so an Allow statement
conditioned on an unrecognized operator became unconditional and
granted access to private objects. Return false instead so an
unrecognized operator fails the condition block and the statement does
not match, matching the fail-closed behavior of the IAM policy engine.

* s3: validate condition operators at upload time only, not load time

Validating condition operators in validateStatement rejected the whole
policy document from ParsePolicy, which SetBucketPolicy uses when loading
stored bucket policies. A legacy policy saved before this change could
contain an unsupported operator, and rejecting it at load time dropped
the entire policy - including unrelated explicit Deny statements - so
the bucket lost its protections. Move the operator check into
ValidateBucketPolicy, which only the PutBucketPolicy handler and admin
UI run at upload time, so legacy policies still load and EvaluateConditions
fails the unsupported statement closed instead.

* s3: drop non-AWS StringLikeIgnoreCase and StringNotLikeIgnoreCase operators

AWS defines StringEqualsIgnoreCase and StringNotEqualsIgnoreCase but
not StringLikeIgnoreCase or StringNotLikeIgnoreCase (StringLike and
StringNotLike are case-sensitive only). Registering the wildcard
IgnoreCase variants made the engine accept operators AWS rejects. Keep
only the two AWS-defined IgnoreCase operators and add a test asserting
the wildcard IgnoreCase names are unsupported.
2026-09-11 22:17:11 -07:00

91 lines
3.1 KiB
Go

package policy_engine
import (
"fmt"
"strings"
)
// MaxBucketPolicySize mirrors AWS S3's 20 KB bucket-policy limit, enforced
// by both writers (the S3 gateway's PutBucketPolicy and the admin UI) so
// neither surface can store a document the other refuses to manage.
const MaxBucketPolicySize = 20 * 1024
// ValidateBucketPolicy performs bucket-specific policy validation, on top of
// the generic structural checks in ValidatePolicy - callers run that first,
// so the version and non-empty-statement rules are not re-checked here. It
// enforces the rules that make a policy document valid as an S3 *bucket*
// policy specifically: every statement must name a Principal, and every
// Resource/NotResource/Action must scope to the given bucket.
//
// This is shared between the S3 gateway's PutBucketPolicy handler
// (weed/s3api/s3api_bucket_policy_handlers.go) and the admin UI
// (weed/admin/dash) so both enforce identical rules.
func ValidateBucketPolicy(policyDoc *PolicyDocument, bucket string) error {
for i, statement := range policyDoc.Statement {
// Bucket policies must have Principal
if statement.Principal == nil {
return fmt.Errorf("statement %d: bucket policies must specify a Principal", i)
}
// Validate resources refer to this bucket
for _, resource := range statement.Resource.Strings() {
if !ResourceMatchesBucket(resource, bucket) {
return fmt.Errorf("statement %d: resource %s does not match bucket %s", i, resource, bucket)
}
}
// Validate NotResources refer to this bucket
if statement.NotResource != nil {
for _, notResource := range statement.NotResource.Strings() {
if !ResourceMatchesBucket(notResource, bucket) {
return fmt.Errorf("statement %d: NotResource %s does not match bucket %s", i, notResource, bucket)
}
}
}
// Validate actions are S3 actions
for _, action := range statement.Action.Strings() {
if !strings.HasPrefix(action, "s3:") {
return fmt.Errorf("statement %d: bucket policies only support S3 actions, got %s", i, action)
}
}
for operator := range statement.Condition {
if _, err := GetConditionEvaluator(operator); err != nil {
return fmt.Errorf("statement %d: unsupported condition operator %q: %v", i, operator, err)
}
}
}
return nil
}
// ResourceMatchesBucket checks if a resource ARN is valid for the given bucket.
func ResourceMatchesBucket(resource, bucket string) bool {
// Accepted formats for S3 bucket policies:
// AWS-style ARNs (standard):
// arn:aws:s3:::bucket-name
// arn:aws:s3:::bucket-name/*
// arn:aws:s3:::bucket-name/path/to/object
// Simplified formats (for convenience):
// bucket-name
// bucket-name/*
// bucket-name/path/to/object
var resourcePath string
const awsPrefix = "arn:aws:s3:::"
// Strip the optional ARN prefix to get the resource path
if path, ok := strings.CutPrefix(resource, awsPrefix); ok {
resourcePath = path
} else {
resourcePath = resource
}
// After stripping the optional ARN prefix, the resource path must
// either match the bucket name exactly, or be a path within the bucket.
return resourcePath == bucket ||
resourcePath == bucket+"/*" ||
strings.HasPrefix(resourcePath, bucket+"/")
}