s3api: support s3:RequestObjectTagKeys in bucket policies (#11993)

* s3api: support s3:RequestObjectTagKeys in bucket policies

Condition operators accept the ForAllValues: and ForAnyValue: qualifiers,
and s3:RequestObjectTagKeys holds the keys of the tags taken from
X-Amz-Tagging wherever s3:RequestObjectTag/<key> is set.

CopyObject no longer stores an empty-key tag for an empty segment in
X-Amz-Tagging, so it keeps the key set a policy has checked.

Fixes #7679

* s3: parse tagging header like the tag handlers, not url.ParseQuery

url.ParseQuery stops at 10,000 parameters, so an X-Amz-Tagging header
padded with empty & segments made requestObjectTags return nil: the copy
handler still stored the denied tag while every s3:RequestObjectTag*
condition saw nothing. Scan non-empty segments the way parseTagsHeader
does so the policy sees the same tags the object gets.

* test: gofmt s3tables test files after #11997

The format check fails on these files on master as merged, so every
PR's S3 Tables Format Check goes red until they are re-formatted.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: user.email <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-24 02:04:39 -07:00
1 parent 7a9a9f10af
commit fb3fb9dcb8
7 files changed
+353 -15

No files matched your search

+153
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
@@ -1051,6 +1052,158 @@ func TestRequestObjectTagConditionDeny(t *testing.T) {
}
}
func evaluateTaggedRequest(engine *PolicyEngine, method, target string, headers map[string]string, tagging string) PolicyEvaluationResult {
if method == "" {
method = http.MethodPut
}
req := httptest.NewRequest(method, "/test-bucket/test-object"+target, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
if tagging != "" {
req.Header.Set(s3_constants.AmzObjectTagging, tagging)
}
return engine.EvaluatePolicy("test-bucket", &PolicyEvaluationArgs{
Action: "s3:PutObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
Conditions: engine.ExtractConditionValuesFromRequest(req),
})
}
func TestRequestObjectTagKeysCondition(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"ForAllValues:StringEquals": {
"s3:RequestObjectTagKeys": ["department", "project"]
},
"Null": {
"s3:RequestObjectTagKeys": "false"
}
}
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"ForAnyValue:StringLike": {
"s3:RequestObjectTagKeys": ["secret*"]
}
}
}
]
}`
if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
copyHeaders := map[string]string{"X-Amz-Copy-Source": "/src-bucket/src-object"}
replaceHeaders := map[string]string{
"X-Amz-Copy-Source": "/src-bucket/src-object",
s3_constants.AmzObjectTaggingDirective: "REPLACE",
}
tests := []struct {
name string
method string
target string
headers map[string]string
tagging string
expected PolicyEvaluationResult
}{
{"listed key allows", "", "", nil, "department=engineering", PolicyResultAllow},
{"all listed keys allow", "", "", nil, "department=engineering&project=alpha", PolicyResultAllow},
{"unlisted key is not allowed", "", "", nil, "department=engineering&owner=bob", PolicyResultIndeterminate},
{"no tagging header is not allowed", "", "", nil, "", PolicyResultIndeterminate},
{"repeated key counts once", "", "", nil, "department=a&department=b", PolicyResultAllow},
{"url-encoded key is decoded", "", "", nil, "depart%6Dent=engineering", PolicyResultAllow},
{"empty segments add no key", "", "", nil, "&department=engineering&", PolicyResultAllow},
{"empty key is not allowed", "", "", nil, "department=engineering&=x", PolicyResultIndeterminate},
{"key case is preserved", "", "", nil, "Department=engineering", PolicyResultIndeterminate},
{"malformed header is not allowed", "", "", nil, "department=engineering&%zz", PolicyResultIndeterminate},
{"secret key is denied", "", "", nil, "secret=1", PolicyResultDeny},
{"secret key behind a padded header is denied", "", "", nil, "secret=1" + strings.Repeat("&", 10001), PolicyResultDeny},
{"secret key among listed keys is denied", "", "", nil, "department=engineering&secret-level=2", PolicyResultDeny},
{"url-encoded secret key is denied", "", "", nil, "secr%65t=1", PolicyResultDeny},
{"secret key on a copy that replaces tags is denied", "", "", replaceHeaders, "secret=1", PolicyResultDeny},
{"secret key on a multipart create is denied", "POST", "?uploads", nil, "secret=1", PolicyResultDeny},
{"header is ignored on a copy that keeps source tags", "", "", copyHeaders, "secret=1", PolicyResultIndeterminate},
{"header is ignored on PutObjectTagging", "", "?tagging", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on a part upload", "", "?partNumber=1&uploadId=x", nil, "secret=1", PolicyResultIndeterminate},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if result := evaluateTaggedRequest(engine, tt.method, tt.target, tt.headers, tt.tagging); result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestRequestObjectTagKeysRequiredTagDeny(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*"
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"ForAllValues:StringNotEquals": {
"s3:RequestObjectTagKeys": ["department", "project"]
}
}
}
]
}`
if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
tests := []struct {
name string
tagging string
expected PolicyEvaluationResult
}{
{"required key passes", "department=engineering", PolicyResultAllow},
{"required key with another key passes", "project=alpha&owner=bob", PolicyResultAllow},
{"only other keys are denied", "owner=bob", PolicyResultDeny},
{"no tagging header is denied", "", PolicyResultDeny},
{"malformed header is denied", "department=engineering&%zz", PolicyResultDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if result := evaluateTaggedRequest(engine, "", "", nil, tt.tagging); result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestExistingObjectTagConditionMultipleTags tests policies with multiple tag conditions
func TestExistingObjectTagConditionMultipleTags(t *testing.T) {
engine := NewPolicyEngine()