// 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 . 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) } }) } }