mirror of
https://github.com/pgsty/minio.git
synced 2026-08-09 07:43:29 +03:00
2f55347f78
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>
483 lines
18 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|