fix(s3): populate s3:prefix from query param for ListObjects policy conditions (#8971)

* fix(s3): populate s3:prefix from query param for ListObjects policy conditions (#8969)

ListObjectsV2/V1 requests with prefix-restricted STS session policies
were denied because:
1. s3:prefix was derived from objectKey, which the auth middleware set to
   the prefix value, but the resource ARN then included the prefix
   (e.g. arn:aws:s3:::bucket/prefix) instead of staying at bucket level
   (arn:aws:s3:::bucket) as AWS requires for ListBucket.
2. When objectKey was empty (no middleware propagation), s3:prefix was
   never populated from the query parameter at all.

Now AuthorizeAction extracts the prefix query parameter directly, sets it
as s3:prefix in the request context, and uses a bucket-level resource ARN
when the objectKey matches the propagated prefix.

* fix(s3): use AWS-style wildcard matching for StringLike policy conditions

filepath.Match treats * as not matching /, which breaks IAM StringLike
conditions on paths (e.g. arn:aws:s3:::bucket/* won't match nested keys).
Replace with a case-sensitive variant of AwsWildcardMatch that correctly
treats * as matching any character including /.

* refactor(s3): replace regex wildcard matching with string-based matcher

Use the existing wildcard.MatchesWildcard utility instead of compiling
and caching regexes for IAM wildcard matching. Removes the regexCache,
its mutex, and the sync import.

* refactor(s3): inline and remove AwsWildcardMatch wrapper functions

Replace all call sites with direct wildcard.MatchesWildcard calls.

* fix(s3): scope s3:prefix condition key to list operations only

The s3:prefix logic was running for all actions, so a GetObject on
"foo/bar" would wrongly populate s3:prefix. Restrict it to action "List"
and always reset resourceObjectKey to "" so the resource ARN stays at
bucket level. Also set s3:prefix to "" when no prefix is provided, so
policies with StringEquals {"s3:prefix": ""} evaluate correctly.
This commit is contained in:
Chris Lu authored and GitHub committed 2026-04-07 13:21:30 -07:00
1 parent a4753b6a3b
commit 79a48256f5
5 files changed
+179 -65

No files matched your search

+147
View File
@@ -214,6 +214,153 @@ func TestS3MultipartUploadWithJWT(t *testing.T) {
}
}
// TestS3ListObjectsV2PrefixCondition tests that ListObjectsV2 requests with a prefix
// query parameter correctly populate s3:prefix in the policy evaluation context and
// use bucket-level resource ARNs, so that policies with s3:prefix conditions work.
// This reproduces the bug reported in https://github.com/seaweedfs/seaweedfs/issues/8969
func TestS3ListObjectsV2PrefixCondition(t *testing.T) {
// Set up IAM system
iamManager := integration.NewIAMManager()
config := &integration.IAMConfig{
STS: &sts.STSConfig{
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12},
Issuer: "test-sts",
SigningKey: []byte("test-signing-key-32-characters-long"),
},
Policy: &policy.PolicyEngineConfig{
DefaultEffect: "Deny",
StoreType: "memory",
},
Roles: &integration.RoleStoreConfig{
StoreType: "memory",
},
}
err := iamManager.Initialize(config, func() string { return "localhost:8888" })
require.NoError(t, err)
setupTestProviders(t, iamManager)
s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888")
require.NotNil(t, s3IAMIntegration)
ctx := context.Background()
// Create a role with a policy that allows ListBucket only with a specific s3:prefix condition.
// This is the pattern used by Lakekeeper-vended STS credentials (issue #8969).
prefixPolicy := &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Sid: "AllowListUnderWarehouse",
Effect: "Allow",
Action: []string{"s3:ListBucket"},
Resource: []string{
"arn:aws:s3:::examples",
},
Condition: map[string]map[string]interface{}{
"StringLike": {
"s3:prefix": []string{"warehouse/*"},
},
},
},
{
Sid: "AllowSTSSessionValidation",
Effect: "Allow",
Action: []string{"sts:ValidateSession"},
Resource: []string{"*"},
},
},
}
iamManager.CreatePolicy(ctx, "", "PrefixRestrictedPolicy", prefixPolicy)
iamManager.CreateRole(ctx, "", "PrefixRestrictedRole", &integration.RoleDefinition{
RoleName: "PrefixRestrictedRole",
TrustPolicy: &policy.PolicyDocument{
Version: "2012-10-17",
Statement: []policy.Statement{
{
Effect: "Allow",
Principal: map[string]interface{}{"Federated": "test-oidc"},
Action: []string{"sts:AssumeRoleWithWebIdentity"},
},
},
},
AttachedPolicies: []string{"PrefixRestrictedPolicy"},
})
// Assume role to get a session token
validJWTToken := createTestJWTEndToEnd(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/PrefixRestrictedRole",
WebIdentityToken: validJWTToken,
RoleSessionName: "prefix-test-session",
})
require.NoError(t, err)
sessionToken := response.Credentials.SessionToken
require.NotEmpty(t, sessionToken)
// Authenticate to get IAM identity
authReq := httptest.NewRequest("GET", "/examples", http.NoBody)
authReq.Header.Set("Authorization", "Bearer "+sessionToken)
identity, errCode := s3IAMIntegration.AuthenticateJWT(ctx, authReq)
require.Equal(t, s3err.ErrNone, errCode, "Authentication should succeed")
tests := []struct {
name string
url string
bucket string
objKey string
expected s3err.ErrorCode
}{
{
name: "ListObjectsV2 with matching prefix query param and empty objectKey",
url: "/examples?list-type=2&prefix=warehouse/data",
bucket: "examples",
objKey: "",
expected: s3err.ErrNone,
},
{
name: "ListObjectsV2 with matching prefix propagated as objectKey",
url: "/examples?list-type=2&prefix=warehouse/data",
bucket: "examples",
objKey: "warehouse/data",
expected: s3err.ErrNone,
},
{
name: "ListObjectsV1 with matching prefix query param",
url: "/examples?prefix=warehouse/files",
bucket: "examples",
objKey: "",
expected: s3err.ErrNone,
},
{
name: "ListObjectsV2 with non-matching prefix should be denied",
url: "/examples?list-type=2&prefix=other/path",
bucket: "examples",
objKey: "",
expected: s3err.ErrAccessDenied,
},
{
name: "ListObjectsV2 with no prefix should be denied",
url: "/examples?list-type=2",
bucket: "examples",
objKey: "",
expected: s3err.ErrAccessDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.url, http.NoBody)
result := s3IAMIntegration.AuthorizeAction(ctx, identity, Action("List"), tt.bucket, tt.objKey, req)
assert.Equal(t, tt.expected, result, "unexpected authorization result for %s", tt.name)
})
}
}
// TestS3CORSWithJWT tests CORS preflight requests with IAM
func TestS3CORSWithJWT(t *testing.T) {
s3Server, iamManager := setupCompleteS3IAMSystem(t)
+17 -9
View File
@@ -250,20 +250,28 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA
return s3err.ErrAccessDenied
}
// Build resource ARN for the S3 operation
resourceArn := buildS3ResourceArn(bucket, objectKey)
// Extract request context for policy conditions
requestContext := extractRequestContext(r)
// Add s3:prefix to request context based on object key
// This ensures that policy conditions referencing s3:prefix (like StringLike)
// work correctly for both ListObjects (where objectKey is the prefix) and
// object operations (where we treat the object key as the prefix for matching)
if objectKey != "" && objectKey != "/" {
requestContext["s3:prefix"] = objectKey
// For list operations, populate the s3:prefix condition key and ensure the
// resource ARN stays at bucket level (matching AWS ListBucket semantics).
// See https://github.com/seaweedfs/seaweedfs/issues/8969
resourceObjectKey := objectKey
if action == "List" {
listPrefix := r.URL.Query().Get("prefix")
if listPrefix != "" {
requestContext["s3:prefix"] = listPrefix
} else if objectKey != "" && objectKey != "/" {
requestContext["s3:prefix"] = objectKey
} else {
requestContext["s3:prefix"] = ""
}
resourceObjectKey = ""
}
// Build resource ARN for the S3 operation
resourceArn := buildS3ResourceArn(bucket, resourceObjectKey)
// Add identity claims to request context for policy variables
// Only add claim keys if they don't already exist (to avoid overwriting request-derived context)
if identity.Claims != nil {