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

+25 -12
View File
@@ -2,9 +2,11 @@ package policy_engine
import (
"fmt"
"maps"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -441,8 +443,11 @@ func (engine *PolicyEngine) ExtractConditionValuesFromRequest(r *http.Request) m
// Note: s3:ExistingObjectTag/<key> conditions are evaluated using objectEntry
// passed to EvaluatePolicy, not extracted from the request.
for key, value := range requestObjectTags(r) {
values[RequestObjectTagPrefix+key] = []string{value}
if tags := requestObjectTags(r); len(tags) > 0 {
for key, value := range tags {
values[RequestObjectTagPrefix+key] = []string{value}
}
values[RequestObjectTagKeys] = slices.Sorted(maps.Keys(tags))
}
// S3 bucket-level conditions
@@ -514,16 +519,24 @@ func requestObjectTags(r *http.Request) map[string]string {
if !storesRequestObjectTags(r) {
return nil
}
// A malformed header is rejected by the handler with InvalidTag.
parsed, err := url.ParseQuery(tagging)
if err != nil {
return nil
}
// putToFiler rejects a repeated key, but CopyObject stores its last
// value, so that is the one the policy has to see.
tags := make(map[string]string, len(parsed))
for key, vals := range parsed {
tags[key] = vals[len(vals)-1]
// Parse the same non-empty segments the tag handlers store: url.ParseQuery
// caps at 10,000 parameters, so a header padded with "&" segments would
// hide stored tag keys from every s3:RequestObjectTag* condition. A
// malformed pair is rejected by the handler with InvalidTag.
tags := make(map[string]string)
for _, segment := range strings.Split(tagging, "&") {
if segment == "" {
continue
}
rawKey, rawValue, _ := strings.Cut(segment, "=")
key, errKey := url.QueryUnescape(rawKey)
value, errValue := url.QueryUnescape(rawValue)
if errKey != nil || errValue != nil {
return nil
}
// putToFiler rejects a repeated key, but CopyObject stores its last
// value, so that is the one the policy has to see.
tags[key] = value
}
return tags
}