Files
minio/cmd/bucket-policy_test.go
T
Feng Ruohang 2f55347f78 fix(iam): bind policy conditions to effective request values
Policy evaluation mixed server-derived identity and transport values with raw headers and query parameters. A client could therefore shadow internal condition keys, synthesize LDAP or JWT resource variables, substitute request tags for stored tags, or make a condition observe a value different from the one the handler actually used.

Partition condition sources, reserve internal names, adopt exact-name lookup from silo-pkg, and bind authorization to the effective request state. Preserve compatible query forms for storage class and upload tags with explicit header precedence, while restricting signature age and existing-object tags to authenticated or server-resolved values.

Tests sweep every supported key across header and query routes and exercise LDAP/OIDC variables, object-lock spelling, STS tags, metadata extraction, and end-to-end policy decisions.

Co-authored-by: ChatGPT <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-04 23:00:30 +08:00

483 lines
18 KiB
Go

// Copyright (c) 2015-2026 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package cmd
import (
"net"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"github.com/minio/minio/internal/auth"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/pkg/v3/policy"
"github.com/minio/pkg/v3/policy/condition"
)
const (
testCondSourceIP = "203.0.113.5"
testCondRemoteILP = testCondSourceIP + ":12345"
)
func condValuesForRequest(t *testing.T, rawURL string, header map[string]string) map[string][]string {
return condValuesForRequestWithTags(t, rawURL, header, "", nil)
}
func condValuesForRequestWithExistingTags(t *testing.T, rawURL string, header map[string]string, existingTags string) map[string][]string {
return condValuesForRequestWithTags(t, rawURL, header, existingTags, nil)
}
func condValuesForRequestWithTags(t *testing.T, rawURL string, header map[string]string, existingTags string, requestTags *string) map[string][]string {
t.Helper()
r, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatal(err)
}
r.RemoteAddr = testCondRemoteILP
for k, v := range header {
r.Header.Set(k, v)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
return getConditionValuesWithTags(r, "us-east-1", auth.Credentials{AccessKey: "lowpriv"}, existingTags, requestTags)
}
func resolvedConditionValues(values map[string][]string, name string) []string {
if v := values[name]; len(v) > 0 {
return v
}
return values[http.CanonicalHeaderKey(name)]
}
// A client must not be able to reach a condition key that the server computes
// for itself. Both routes are covered: a header whose canonical spelling
// collides with the key name, and a query parameter that collides with it
// exactly. The query route is the sharper one, because the merge appended to
// the server's value rather than replacing it and a condition function matches
// when any single value matches.
func TestGetConditionValuesRejectsClientSuppliedServerKeys(t *testing.T) {
honest := condValuesForRequest(t, "http://minio.local/bkt/obj", nil)
for _, kn := range condition.AllSupportedKeys {
name := kn.ToKey().Name()
if _, clientSupplied := clientSuppliedConditionKeys[name]; clientSupplied {
continue // the request is where this one is supposed to come from
}
// Deliberately not skipped when the server left the key empty. An empty
// name is exactly as forgeable as a populated one, and the keys the
// server has no value for - most of jwt: and ldap: - are the ones a
// resource variable expands.
want := honest[name]
canonical := http.CanonicalHeaderKey(name)
t.Run("query/"+name, func(t *testing.T) {
got := condValuesForRequest(t,
"http://minio.local/bkt/obj?"+url.Values{name: {"ATTACKER"}}.Encode(), nil)
if slices.Contains(got[name], "ATTACKER") {
t.Errorf("?%s= reached %v, server computed %v", name, got[name], want)
}
if !slices.Equal(got[name], want) {
t.Errorf("%v changed to %v", want, got[name])
}
})
// aws:Referer is read out of the Referer header, so the header is its
// source of truth rather than a way to forge it. aws:UserAgent is not
// in the same position: it comes from User-Agent, which does not
// canonicalise to "Useragent".
if kn == condition.AWSReferer {
continue
}
t.Run("header/"+canonical, func(t *testing.T) {
got := condValuesForRequest(t, "http://minio.local/bkt/obj",
map[string]string{canonical: "ATTACKER"})
// The lookup the policy engine itself performs, exact name first
// with the canonical form as fallback.
seen := got[name]
if len(seen) == 0 {
seen = got[canonical]
}
if slices.Contains(seen, "ATTACKER") {
t.Errorf("%s: header reached the lookup as %v, server computed %v",
canonical, seen, want)
}
})
}
}
func TestGetConditionValuesUsesActualRequestSource(t *testing.T) {
for name, source := range clientSuppliedConditionKeys {
t.Run(name, func(t *testing.T) {
fromHeader := condValuesForRequest(t, "http://minio.local/bkt/obj",
map[string]string{name: "HEADER"})
fromQuery := condValuesForRequest(t,
"http://minio.local/bkt/obj?"+url.Values{name: {"QUERY"}}.Encode(), nil)
fromCanonicalQuery := condValuesForRequest(t,
"http://minio.local/bkt/obj?"+url.Values{http.CanonicalHeaderKey(name): {"QUERY"}}.Encode(), nil)
if got, want := slices.Contains(resolvedConditionValues(fromHeader, name), "HEADER"), source&conditionValueFromHeader != 0; got != want {
t.Errorf("header accepted=%v, want %v: %v", got, want, fromHeader)
}
if got, want := slices.Contains(resolvedConditionValues(fromQuery, name), "QUERY"), source&conditionValueFromQuery != 0; got != want {
t.Errorf("query accepted=%v, want %v: %v", got, want, fromQuery)
}
canonicalQueryAllowed := name == strings.ToLower(xhttp.AmzStorageClass)
if got := slices.Contains(resolvedConditionValues(fromCanonicalQuery, name), "QUERY"); got != canonicalQueryAllowed {
t.Errorf("case-variant query accepted=%v, want %v: %v", got, canonicalQueryAllowed, fromCanonicalQuery)
}
})
}
storageURL := "http://minio.local/bkt/obj?" + url.Values{
strings.ToLower(xhttp.AmzStorageClass): {"QUERY"},
}.Encode()
storageValues := condValuesForRequest(t, storageURL, map[string]string{xhttp.AmzStorageClass: "HEADER"})
if got := resolvedConditionValues(storageValues, strings.ToLower(xhttp.AmzStorageClass)); !slices.Equal(got, []string{"HEADER"}) {
t.Errorf("storage class did not use header precedence: %v", got)
}
fromQuery := condValuesForRequest(t,
"http://minio.local/bkt/obj?"+url.Values{xhttp.AmzObjectLockMode: {"COMPLIANCE"}}.Encode(), nil)
if got := resolvedConditionValues(fromQuery, "object-lock-mode"); len(got) != 0 {
t.Errorf("object-lock query value reached header condition as %v", got)
}
}
func TestGetConditionValuesUsesEffectiveRequestTags(t *testing.T) {
rawURL := "http://minio.local/bkt/obj?" + url.Values{
strings.ToLower(xhttp.AmzObjectTagging): {"security=public&virus=true"},
}.Encode()
// Generic operations such as CopyObject must not gain RequestObjectTag
// values from a query parameter they do not consume.
withoutEffectiveTags := condValuesForRequest(t, rawURL, nil)
if len(withoutEffectiveTags["RequestObjectTag/security"]) != 0 || len(withoutEffectiveTags["RequestObjectTagKeys"]) != 0 {
t.Fatalf("query tags leaked into a generic operation: %v", withoutEffectiveTags)
}
effectiveTags := "security=public&virus=true"
withEffectiveTags := condValuesForRequestWithTags(t, rawURL, nil, "", &effectiveTags)
if !slices.Equal(withEffectiveTags["RequestObjectTag/security"], []string{"public"}) {
t.Fatalf("effective request tag missing: %v", withEffectiveTags)
}
if !slices.Contains(withEffectiveTags["RequestObjectTagKeys"], "security") ||
!slices.Contains(withEffectiveTags["RequestObjectTagKeys"], "virus") {
t.Fatalf("effective request tag keys missing: %v", withEffectiveTags["RequestObjectTagKeys"])
}
security, err := condition.NewStringEqualsFunc("", condition.NewKey(condition.RequestObjectTag, "security"), "public")
if err != nil {
t.Fatal(err)
}
allowedKeys, err := condition.NewStringLikeFunc("ForAllValues", condition.RequestObjectTagKeys.ToKey(), "security", "virus")
if err != nil {
t.Fatal(err)
}
conditions := condition.NewFunctions(security, allowedKeys)
if conditions.Evaluate(withoutEffectiveTags) {
t.Fatal("query upload satisfied request-tag policy without effective tags")
}
if !conditions.Evaluate(withEffectiveTags) {
t.Fatal("effective query tags did not satisfy request-tag policy")
}
}
func TestBucketPolicySSEConditionUsesHeader(t *testing.T) {
fn, err := condition.NewStringEqualsFunc("", condition.S3XAmzServerSideEncryption.ToKey(), "aws:kms")
if err != nil {
t.Fatal(err)
}
conditions := condition.NewFunctions(fn)
if conditions.Evaluate(condValuesForRequest(t,
"http://minio.local/bkt/obj?x-amz-server-side-encryption=aws%3Akms", nil)) {
t.Error("query parameter satisfied a condition on the SSE request header")
}
if !conditions.Evaluate(condValuesForRequest(t, "http://minio.local/bkt/obj",
map[string]string{xhttp.AmzServerSideEncryption: "aws:kms"})) {
t.Error("SSE request header did not satisfy its condition")
}
}
// The end to end shape of the bypass: an IpAddress condition restricting a
// bucket to an internal range, against a request from outside it.
func TestBucketPolicySourceIPCannotBeForged(t *testing.T) {
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
if err != nil {
t.Fatal(err)
}
fn, err := condition.NewIPAddressFunc(condition.AWSSourceIP.ToKey(), cidr)
if err != nil {
t.Fatal(err)
}
bp := policy.BucketPolicy{
Version: policy.DefaultVersion,
Statements: []policy.BPStatement{{
Effect: policy.Allow,
Principal: policy.NewPrincipal("*"),
Actions: policy.NewActionSet(policy.GetObjectAction),
Resources: policy.NewResourceSet(policy.NewResource("bkt/*")),
Conditions: condition.NewFunctions(fn),
}},
}
allowed := func(rawURL string, header map[string]string) bool {
return bp.IsAllowed(policy.BucketPolicyArgs{
Action: policy.GetObjectAction,
BucketName: "bkt",
ObjectName: "obj",
ConditionValues: condValuesForRequest(t, rawURL, header),
})
}
if allowed("http://minio.local/bkt/obj", nil) {
t.Fatal("baseline: an address outside 10.0.0.0/8 must not satisfy the condition")
}
if allowed("http://minio.local/bkt/obj?SourceIp=10.1.2.3", nil) {
t.Error("a query parameter forged aws:SourceIp")
}
if allowed("http://minio.local/bkt/obj", map[string]string{"Sourceip": "10.1.2.3"}) {
t.Error("a header forged aws:SourceIp")
}
}
// "Deny unless the connection is TLS" is the usual hardening statement, and
// aws:SecureTransport is computed from r.TLS.
func TestBucketPolicySecureTransportCannotBeForged(t *testing.T) {
fn, err := condition.NewBoolFunc(condition.AWSSecureTransport.ToKey(), false)
if err != nil {
t.Fatal(err)
}
bp := policy.BucketPolicy{
Version: policy.DefaultVersion,
Statements: []policy.BPStatement{
{
Effect: policy.Allow, Principal: policy.NewPrincipal("*"),
Actions: policy.NewActionSet(policy.GetObjectAction),
Resources: policy.NewResourceSet(policy.NewResource("bkt/*")),
},
{
Effect: policy.Deny, Principal: policy.NewPrincipal("*"),
Actions: policy.NewActionSet(policy.GetObjectAction),
Resources: policy.NewResourceSet(policy.NewResource("bkt/*")),
Conditions: condition.NewFunctions(fn),
},
},
}
allowed := func(rawURL string, header map[string]string) bool {
return bp.IsAllowed(policy.BucketPolicyArgs{
Action: policy.GetObjectAction,
BucketName: "bkt",
ObjectName: "obj",
ConditionValues: condValuesForRequest(t, rawURL, header),
})
}
// r.TLS is nil throughout, so every one of these is a plaintext request.
if allowed("http://minio.local/bkt/obj", nil) {
t.Fatal("baseline: a plaintext request must be denied")
}
if allowed("http://minio.local/bkt/obj?SecureTransport=true", nil) {
t.Error("a query parameter forged aws:SecureTransport")
}
if allowed("http://minio.local/bkt/obj", map[string]string{"Securetransport": "true"}) {
t.Error("a header forged aws:SecureTransport")
}
}
// Reserving the server's own keys must not stop the request from supplying the
// values that are client-derived by design.
func TestGetConditionValuesKeepsClientDerivedKeys(t *testing.T) {
got := condValuesForRequest(t, "http://minio.local/bkt/obj?prefix=team%2F",
map[string]string{
xhttp.AmzObjectLockMode: "GOVERNANCE",
xhttp.AmzServerSideEncryption: "aws:kms",
"X-Amz-Meta-Team": "storage",
xhttp.AmzObjectTagging: "project=silo",
})
for _, tc := range []struct {
key string
want string
}{
{"Object-Lock-Mode", "GOVERNANCE"},
{xhttp.AmzServerSideEncryption, "aws:kms"},
{"X-Amz-Meta-Team", "storage"},
{"RequestObjectTag/project", "silo"},
{"prefix", "team/"},
} {
if !slices.Contains(got[tc.key], tc.want) {
t.Errorf("%s: expected %q, got %v", tc.key, tc.want, got[tc.key])
}
}
if !slices.Contains(got["RequestObjectTagKeys"], "project") {
t.Errorf("RequestObjectTagKeys: expected project, got %v", got["RequestObjectTagKeys"])
}
if len(got["ExistingObjectTag/project"]) != 0 {
t.Errorf("request tags leaked into ExistingObjectTag: %v", got["ExistingObjectTag/project"])
}
}
func TestGetConditionValuesSeparatesRequestAndExistingTags(t *testing.T) {
got := condValuesForRequestWithExistingTags(t, "http://minio.local/bkt/obj",
map[string]string{xhttp.AmzObjectTagging: "project=request&new=yes"},
"project=stored&old=yes")
for _, tc := range []struct {
key string
want string
}{
{"RequestObjectTag/project", "request"},
{"RequestObjectTag/new", "yes"},
{"ExistingObjectTag/project", "stored"},
{"ExistingObjectTag/old", "yes"},
} {
if !slices.Equal(got[tc.key], []string{tc.want}) {
t.Errorf("%s: expected %q, got %v", tc.key, tc.want, got[tc.key])
}
}
if len(got["ExistingObjectTag/new"]) != 0 || len(got["RequestObjectTag/old"]) != 0 {
t.Errorf("tag sources crossed: request new=%v, existing old=%v",
got["ExistingObjectTag/new"], got["RequestObjectTag/old"])
}
}
// Keys the server did not populate for this request are as forgeable as ones it
// did, so the reservation cannot depend on presence.
func TestGetConditionValuesRejectsAbsentInternalKeys(t *testing.T) {
for _, key := range []string{
"signatureAge",
"groups",
"DurationSeconds",
"ExistingObjectTag/security",
"RequestObjectTag/security",
"RequestObjectTagKeys",
"object-lock-mode",
"object-lock-remaining-retention-days",
} {
t.Run(key, func(t *testing.T) {
got := condValuesForRequest(t,
"http://minio.local/bkt/obj?"+url.Values{key: {"ATTACKER"}}.Encode(), nil)
if slices.Contains(got[key], "ATTACKER") {
t.Errorf("?%s= was accepted into the condition values as %v", key, got[key])
}
})
}
}
func TestGetConditionValuesOnlyAcceptsPresignedSignatureAge(t *testing.T) {
const signatureAgeHeader = "x-amz-signature-age"
for _, tc := range []struct {
name string
target string
headers map[string]string
want bool
}{
{
name: "anonymous client header",
target: "http://minio.local/bkt/obj",
headers: map[string]string{signatureAgeHeader: "1"},
},
{
name: "header-signed client header",
target: "http://minio.local/bkt/obj",
headers: map[string]string{
xhttp.Authorization: signV4Algorithm + " attacker",
signatureAgeHeader: "1",
},
},
{
name: "presigned verifier value",
target: "http://minio.local/bkt/obj?" + url.Values{
xhttp.AmzCredential: {"access/20260803/us-east-1/s3/aws4_request"},
}.Encode(),
headers: map[string]string{signatureAgeHeader: "250"},
want: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := condValuesForRequest(t, tc.target, tc.headers)
_, ok := got["signatureAge"]
if ok != tc.want {
t.Fatalf("signatureAge presence: expected %v, got %v", tc.want, got["signatureAge"])
}
})
}
}
// The object-lock value is stored under the header spelling while the policy key
// that reads it is lower case. Reserving only one spelling lets the other be
// supplied and resolved in its place - which the policy package's exact-name
// lookup then prefers over the real one.
func TestGetConditionValuesObjectLockSpelling(t *testing.T) {
got := condValuesForRequest(t,
"http://minio.local/bkt/obj?object-lock-mode=COMPLIANCE",
map[string]string{xhttp.AmzObjectLockMode: "GOVERNANCE"})
if v, ok := got["object-lock-mode"]; ok {
t.Errorf("the lower-case spelling was accepted: %v", v)
}
if !slices.Equal(got["Object-Lock-Mode"], []string{"GOVERNANCE"}) {
t.Errorf("expected the header value to stand, got %v", got["Object-Lock-Mode"])
}
fn, err := condition.NewStringEqualsFunc("",
condition.S3ObjectLockMode.ToKey(), "COMPLIANCE")
if err != nil {
t.Fatal(err)
}
if condition.NewFunctions(fn).Evaluate(got) {
t.Error("a policy requiring COMPLIANCE was satisfied by a GOVERNANCE request")
}
}
// Resource variables read the condition map directly, so a forgeable key is a
// forgeable resource path. ${ldap:user} and ${jwt:preferred_username} are the
// home-directory idiom for LDAP and OIDC deployments; the server derives them
// from the credential, and a request must not be able to answer them.
func TestBucketPolicyResourceVariableCannotBeForged(t *testing.T) {
for _, tc := range []struct{ variable, param, value string }{
{"${ldap:user}", "user", "alice"},
{"${ldap:username}", "username", "alice"},
{"${jwt:preferred_username}", "preferred_username", "alice"},
{"${jwt:sub}", "sub", "alice"},
{"${aws:username}", "username", "alice"},
} {
t.Run(tc.variable, func(t *testing.T) {
bp := policy.BucketPolicy{Version: policy.DefaultVersion, Statements: []policy.BPStatement{{
Effect: policy.Allow,
Principal: policy.NewPrincipal("*"),
Actions: policy.NewActionSet(policy.GetObjectAction),
Resources: policy.NewResourceSet(policy.NewResource("bkt/" + tc.variable + "/*")),
}}}
args := policy.BucketPolicyArgs{
Action: policy.GetObjectAction, BucketName: "bkt", ObjectName: tc.value + "/secret",
}
args.ConditionValues = condValuesForRequest(t,
"http://minio.local/bkt/"+tc.value+"/secret?"+
url.Values{tc.param: {tc.value}}.Encode(), nil)
if bp.IsAllowed(args) {
t.Errorf("?%s=%s expanded %s and granted the prefix", tc.param, tc.value, tc.variable)
}
})
}
}