mirror of
https://github.com/pgsty/minio.git
synced 2026-08-09 07:43:29 +03:00
744a9dcd71
A bucket policy that allows s3:DeleteObject only when s3:versionid is null
-- Condition {"Null": {"s3:versionid": "true"}}, the idiom for "let clients
delete current objects but not roll back versions" -- denied every delete,
including the version-less ones it was meant to permit (upstream issue
minio/minio#21735).
getConditionValues wrote "versionid": {""} unconditionally. The condition
engine decides Null by slice length (nullfunc.evaluate), so a present-but-
empty value reads as "key present": Null:true never matched and Null:false
always did. Absent and empty were indistinguishable.
Writing the key only when the request names a version fixes the reported
case but, alone, opens a worse one. DeleteObjects carries each object's
version in the XML body, which getConditionValues -- reading only r.Form --
never sees. A body version would then vanish from the map, read as null,
and a policy meant to protect old versions would authorize deleting a
specific one. So authorization also rebinds versionid to the effective,
server-resolved reqInfo.VersionID for DeleteObjectAction: the per-entry
body value that checkRequestAuthTypeWithVID already sets in the
DeleteObjects loop, deleting the key when that value is empty. A
query-level ?versionId on a DeleteObjects POST no longer leaks into any
entry's decision.
Finally, trim the version the condition builder reads. newContext and
getOpts both TrimSpace it before the object layer acts, so an untrimmed
value here let a padded ?versionId=V%20 present a different s3:versionid
than the version actually operated on, sidestepping a Deny keyed on
StringEquals s3:versionid. DeleteObjectAction was already immune via the
trimmed reqInfo value; this covers GetObject, tagging, retention, and the
copy-source read.
Tests: an end-to-end DeleteObjects against a Null:{s3:versionid:true}
policy over versioned objects (with a decoy query versionId proving the
per-entry body value wins), and a unit test asserting key presence,
trimming, and the copy-source fallback.
Co-authored-by: ChatGPT <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
579 lines
22 KiB
Go
579 lines
22 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"
|
|
"github.com/minio/minio/internal/handlers"
|
|
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 TestGetConditionValuesVersionIDPresence(t *testing.T) {
|
|
nullVersionID, err := condition.NewNullFunc(condition.S3VersionID.ToKey(), true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
withoutVersionID := condValuesForRequest(t, "http://minio.local/bkt/obj", nil)
|
|
if _, ok := withoutVersionID["versionid"]; ok {
|
|
t.Fatalf("an absent versionId was exposed to policy evaluation as %v", withoutVersionID["versionid"])
|
|
}
|
|
if !condition.NewFunctions(nullVersionID).Evaluate(withoutVersionID) {
|
|
t.Fatal("Null s3:versionid=true did not match a request without versionId")
|
|
}
|
|
|
|
const versionID = "7f4b6b5f-bf25-4e98-95df-90cba8070dd8"
|
|
withVersionID := condValuesForRequest(t,
|
|
"http://minio.local/bkt/obj?"+url.Values{xhttp.VersionID: {versionID}}.Encode(), nil)
|
|
if got := withVersionID["versionid"]; !slices.Equal(got, []string{versionID}) {
|
|
t.Fatalf("expected versionId %q, got %v", versionID, got)
|
|
}
|
|
if condition.NewFunctions(nullVersionID).Evaluate(withVersionID) {
|
|
t.Fatal("Null s3:versionid=true matched a request with versionId")
|
|
}
|
|
|
|
copySourceVersion := condValuesForRequest(t, "http://minio.local/bkt/copied", map[string]string{
|
|
xhttp.AmzCopySource: "/source-bucket/source-object?" + url.Values{xhttp.VersionID: {versionID}}.Encode(),
|
|
})
|
|
if got := copySourceVersion["versionid"]; !slices.Equal(got, []string{versionID}) {
|
|
t.Fatalf("copy source versionId was lost: got %v", got)
|
|
}
|
|
|
|
// The object layer trims the version before acting on it; the condition value
|
|
// must be the same effective string, or a padded ?versionId=V%20 would let a
|
|
// StringEquals/Deny on s3:versionid see a different value than the one deleted.
|
|
paddedVersion := condValuesForRequest(t,
|
|
"http://minio.local/bkt/obj?"+url.Values{xhttp.VersionID: {versionID + " "}}.Encode(), nil)
|
|
if got := paddedVersion["versionid"]; !slices.Equal(got, []string{versionID}) {
|
|
t.Fatalf("a padded versionId was not trimmed to the effective value: got %v", got)
|
|
}
|
|
|
|
paddedCopySource := condValuesForRequest(t, "http://minio.local/bkt/copied", map[string]string{
|
|
xhttp.AmzCopySource: "/source-bucket/source-object?" + url.Values{xhttp.VersionID: {versionID + " "}}.Encode(),
|
|
})
|
|
if got := paddedCopySource["versionid"]; !slices.Equal(got, []string{versionID}) {
|
|
t.Fatalf("a padded copy source versionId was not trimmed: got %v", got)
|
|
}
|
|
|
|
// A whitespace-only versionId names no version once trimmed, exactly as the
|
|
// object layer treats it, so the key must be absent and Null:true must match.
|
|
blankVersion := condValuesForRequest(t,
|
|
"http://minio.local/bkt/obj?"+url.Values{xhttp.VersionID: {" "}}.Encode(), nil)
|
|
if _, ok := blankVersion["versionid"]; ok {
|
|
t.Fatalf("a whitespace-only versionId was exposed to policy evaluation as %v", blankVersion["versionid"])
|
|
}
|
|
if !condition.NewFunctions(nullVersionID).Evaluate(blankVersion) {
|
|
t.Fatal("Null s3:versionid=true did not match a request whose versionId was only whitespace")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// aws:SourceIp must be whatever the hardened resolver decided and nothing else.
|
|
// The resolver is where the forwarded-header trust policy is enforced and where
|
|
// its three modes are tested (internal/handlers/proxy_test.go); this pins the
|
|
// join, so the condition value cannot drift onto some other derivation that the
|
|
// policy would not cover.
|
|
//
|
|
// It also records the default-mode contract: with no trust policy configured,
|
|
// each of the three forwarded headers still sets aws:SourceIp, and so an
|
|
// IpAddress condition is only as good as the network path to the API port.
|
|
// Enforcing such a condition against a client with direct access requires
|
|
// MINIO_API_TRUSTED_PROXIES or _MINIO_API_XFF_HEADER=off.
|
|
func TestGetConditionValuesSourceIPMatchesResolver(t *testing.T) {
|
|
for _, header := range []map[string]string{
|
|
nil,
|
|
{"X-Forwarded-For": "10.1.2.3"},
|
|
{"X-Real-IP": "10.1.2.3"},
|
|
{"Forwarded": "for=10.1.2.3"},
|
|
{"X-Forwarded-For": "10.1.2.3, 198.51.100.9"},
|
|
} {
|
|
r, err := http.NewRequest(http.MethodGet, "http://minio.local/bkt/obj", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.RemoteAddr = testCondRemoteILP
|
|
for k, v := range header {
|
|
r.Header.Set(k, v)
|
|
}
|
|
|
|
got := resolvedConditionValues(condValuesForRequest(t, "http://minio.local/bkt/obj", header), condition.AWSSourceIP.ToKey().Name())
|
|
want := handlers.GetSourceIPRaw(r)
|
|
if len(got) != 1 || got[0] != want {
|
|
t.Errorf("headers %v: aws:SourceIp = %v, resolver returned %q", header, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// "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)
|
|
}
|
|
})
|
|
}
|
|
}
|