Skip to content

Commit a92c54d

Browse files
authored
Merge pull request #4 from pgsty/codex/policy-correctness-20260908
fix: preserve policy denies and bound wildcard matching
2 parents d07ec47 + 3086fd8 commit a92c54d

7 files changed

Lines changed: 515 additions & 26 deletions

File tree

policy/condition/stringfunc.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ func (f stringFunc) name() name {
8282
func (f stringFunc) String() string {
8383
valueStrings := f.values.ToSlice()
8484
sort.Strings(valueStrings)
85-
return fmt.Sprintf("%v:%v:%v", f.n, f.k, valueStrings)
85+
// Equality and statement hashing use this representation; preserve value boundaries.
86+
return fmt.Sprintf("%v:%v:%q", f.n, f.k, valueStrings)
8687
}
8788

8889
func (f stringFunc) toMap() map[Key]ValueSet {

policy/condition/stringfunc_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,32 @@ import (
2525
"github.com/minio/minio-go/v7/pkg/set"
2626
)
2727

28+
func TestStringFuncValueBoundaries(t *testing.T) {
29+
for _, tc := range []struct {
30+
left, right []string
31+
equal bool
32+
}{
33+
{[]string{"a b"}, []string{"a", "b"}, false},
34+
{[]string{"a b", "c"}, []string{"a", "b c"}, false},
35+
{[]string{"", "a"}, []string{" a"}, false},
36+
{[]string{`a" "b`}, []string{"a", "b"}, false},
37+
{[]string{`a\nb`}, []string{"a\nb"}, false},
38+
{[]string{"a b", "c"}, []string{"c", "a b"}, true},
39+
} {
40+
left, err := NewStringEqualsFunc("", S3Prefix.ToKey(), tc.left...)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
right, err := NewStringEqualsFunc("", S3Prefix.ToKey(), tc.right...)
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
if got := NewFunctions(left).Equals(NewFunctions(right)); got != tc.equal {
49+
t.Errorf("condition values %q and %q: Equals = %v, want %v", tc.left, tc.right, got, tc.equal)
50+
}
51+
}
52+
}
53+
2854
func TestStringEqualsFuncEvaluate(t *testing.T) {
2955
case1Function, err := newStringEqualsFunc(S3XAmzCopySource.ToKey(), NewValueSet(NewStringValue("mybucket/myobject")), "")
3056
if err != nil {

policy/policy.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,16 @@ type Policy struct {
127127

128128
// HasDenyStatement returns if the policy has a deny statement.
129129
func (iamp *Policy) HasDenyStatement() bool {
130-
return iamp.hasDeny
130+
if iamp.hasDeny {
131+
return true
132+
}
133+
// Directly constructed policies have not populated the cached flag.
134+
for i := range iamp.Statements {
135+
if iamp.Statements[i].Effect == Deny {
136+
return true
137+
}
138+
}
139+
return false
131140
}
132141

133142
// MatchResource matches resource with match resource patterns

policy/policy_regression_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) 2026 Feng Ruohang
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
package policy
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"testing"
12+
13+
"github.com/pgsty/silo-pkg/v3/policy/condition"
14+
)
15+
16+
func TestPolicyParsingPreservesNotResourceDenies(t *testing.T) {
17+
for _, count := range []int{4, 10, 11, 20} {
18+
p := Policy{
19+
Version: DefaultVersion,
20+
Statements: []Statement{
21+
{Effect: Allow, Actions: NewActionSet(GetObjectAction), Resources: NewResourceSet(NewResource("*"))},
22+
{Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("public/*"), NewResource("shared/*"))},
23+
{Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("other/*"), NewResource("shared/*"))},
24+
},
25+
}
26+
for len(p.Statements) < count {
27+
p.Statements = append(p.Statements, Statement{
28+
Effect: Allow, Actions: NewActionSet(PutObjectAction),
29+
Resources: NewResourceSet(NewResource(fmt.Sprintf("filler%d/*", len(p.Statements)))),
30+
})
31+
}
32+
data, err := json.Marshal(p)
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
for _, parser := range []struct {
37+
name string
38+
parse func(io.Reader) (*Policy, error)
39+
}{
40+
{"read", ParseConfig},
41+
{"write", ParseConfigStrict},
42+
} {
43+
t.Run(fmt.Sprintf("%s/%d", parser.name, count), func(t *testing.T) {
44+
parsed, err := parser.parse(bytes.NewReader(data))
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
if got := len(parsed.Statements); got != count {
49+
t.Errorf("kept %d statements, want %d", got, count)
50+
}
51+
for _, bucket := range []string{"public", "other", "shared"} {
52+
want := bucket == "shared"
53+
if got := parsed.IsAllowed(Args{Action: GetObjectAction, BucketName: bucket, ObjectName: "file"}); got != want {
54+
t.Errorf("GetObject %s/file = %v, want %v", bucket, got, want)
55+
}
56+
}
57+
})
58+
}
59+
}
60+
}
61+
62+
func TestPolicyDeduplicationPreservesConditionValues(t *testing.T) {
63+
for _, count := range []int{3, 10, 11, 20} {
64+
p := Policy{Version: DefaultVersion, Statements: []Statement{{
65+
Effect: Allow, Actions: NewActionSet(ListBucketAction), Resources: NewResourceSet(NewResource("bucket")),
66+
}}}
67+
for _, values := range [][]string{{"a b"}, {"a", "b"}} {
68+
f, err := condition.NewStringEqualsFunc("", condition.S3Prefix.ToKey(), values...)
69+
if err != nil {
70+
t.Fatal(err)
71+
}
72+
p.Statements = append(p.Statements, Statement{
73+
Effect: Deny, Actions: NewActionSet(ListBucketAction), Resources: NewResourceSet(NewResource("bucket")),
74+
Conditions: condition.NewFunctions(f),
75+
})
76+
}
77+
for len(p.Statements) < count {
78+
p.Statements = append(p.Statements, Statement{
79+
Effect: Allow, Actions: NewActionSet(GetObjectAction),
80+
Resources: NewResourceSet(NewResource(fmt.Sprintf("filler%d/*", len(p.Statements)))),
81+
})
82+
}
83+
data, err := json.Marshal(p)
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
for _, mode := range []string{"read", "write", "merge"} {
88+
t.Run(fmt.Sprintf("%s/%d", mode, count), func(t *testing.T) {
89+
var parsed *Policy
90+
var err error
91+
switch mode {
92+
case "read":
93+
parsed, err = ParseConfig(bytes.NewReader(data))
94+
case "write":
95+
parsed, err = ParseConfigStrict(bytes.NewReader(data))
96+
case "merge":
97+
merged := MergePolicies(p, Policy{Version: DefaultVersion, Statements: []Statement{p.Statements[1].Clone()}})
98+
parsed = &merged
99+
}
100+
if err != nil {
101+
t.Fatal(err)
102+
}
103+
if got := len(parsed.Statements); got != count {
104+
t.Errorf("kept %d statements, want %d", got, count)
105+
}
106+
for _, prefix := range []string{"a", "b", "a b", "other"} {
107+
args := Args{Action: ListBucketAction, BucketName: "bucket", ConditionValues: map[string][]string{"prefix": {prefix}}}
108+
if got, want := parsed.IsAllowed(args), prefix == "other"; got != want {
109+
t.Errorf("ListBucket prefix %q = %v, want %v", prefix, got, want)
110+
}
111+
}
112+
})
113+
}
114+
}
115+
}
116+
117+
func TestMergePoliciesPreservesNotResourceDenies(t *testing.T) {
118+
allow := Statement{Effect: Allow, Actions: NewActionSet(GetObjectAction), Resources: NewResourceSet(NewResource("*"))}
119+
public := Statement{Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("public/*"), NewResource("shared/*"))}
120+
other := Statement{Effect: Deny, Actions: NewActionSet(GetObjectAction), NotResources: NewResourceSet(NewResource("other/*"), NewResource("shared/*"))}
121+
merged := MergePolicies(
122+
Policy{Version: DefaultVersion, Statements: []Statement{allow, public}},
123+
Policy{Version: DefaultVersion, Statements: []Statement{other, public.Clone()}},
124+
)
125+
if got := len(merged.Statements); got != 3 {
126+
t.Errorf("kept %d statements, want 3 distinct statements", got)
127+
}
128+
for _, bucket := range []string{"public", "other", "shared"} {
129+
want := bucket == "shared"
130+
if got := merged.IsAllowed(Args{Action: GetObjectAction, BucketName: bucket, ObjectName: "file"}); got != want {
131+
t.Errorf("GetObject %s/file = %v, want %v", bucket, got, want)
132+
}
133+
}
134+
}
135+
136+
func TestHasDenyStatementWithoutParsing(t *testing.T) {
137+
for _, tt := range []struct {
138+
name string
139+
effects []Effect
140+
want bool
141+
}{
142+
{"empty", nil, false},
143+
{"allow", []Effect{Allow}, false},
144+
{"deny", []Effect{Deny}, true},
145+
{"allow-then-deny", []Effect{Allow, Deny}, true},
146+
} {
147+
t.Run(tt.name, func(t *testing.T) {
148+
p := Policy{Version: DefaultVersion}
149+
for _, effect := range tt.effects {
150+
p.Statements = append(p.Statements, Statement{
151+
Effect: effect, Actions: NewActionSet(GetObjectAction),
152+
Resources: NewResourceSet(NewResource("*")),
153+
})
154+
}
155+
if got := p.HasDenyStatement(); got != tt.want {
156+
t.Errorf("HasDenyStatement() = %v, want %v", got, tt.want)
157+
}
158+
p.updateActionIndex()
159+
if got := p.HasDenyStatement(); got != tt.want {
160+
t.Errorf("indexed HasDenyStatement() = %v, want %v", got, tt.want)
161+
}
162+
})
163+
}
164+
for _, p := range DefaultPolicies {
165+
if p.Name == "readonly" {
166+
if !p.Definition.HasDenyStatement() {
167+
t.Error("readonly's explicit Deny must be reported before parsing")
168+
}
169+
return
170+
}
171+
}
172+
t.Fatal("readonly policy not found")
173+
}

policy/statement.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,11 @@ func (statement Statement) hash(seed uint64) [16]byte {
731731
xorTo(&h, xxh3.HashString128Seed(res.Pattern+res.Type.String(), seed+6))
732732
}
733733

734+
xorInt(&h, len(statement.NotResources), seed+9)
735+
for res := range statement.NotResources {
736+
xorTo(&h, xxh3.HashString128Seed(res.Pattern+res.Type.String(), seed+10))
737+
}
738+
734739
xorInt(&h, len(statement.Conditions), seed+7)
735740
for _, cond := range statement.Conditions {
736741
xorTo(&h, xxh3.HashString128Seed(cond.String(), seed+8))

wildcard/match.go

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,19 @@
1717

1818
package wildcard
1919

20-
import (
21-
"cmp"
22-
"strings"
23-
)
20+
import "strings"
2421

2522
// MatchSimple - finds whether the text matches/satisfies the pattern string.
2623
// supports '*' wildcard in the pattern and ? for single characters.
27-
// Only difference to Match is that `?` at the end is optional,
28-
// meaning `a?` pattern will match name `a`.
24+
// Unlike Match, reaching '?' after name is exhausted accepts the rest of the
25+
// pattern. For example, both "a?" and "a?b" match "a".
2926
func MatchSimple(pattern, name string) bool {
3027
if pattern == "" {
3128
return name == pattern
3229
}
3330
if pattern == "*" {
3431
return true
3532
}
36-
// Do an extended wildcard '*' and '?' match.
3733
return deepMatchRune(name, pattern, true)
3834
}
3935

@@ -54,29 +50,73 @@ func Match(pattern, name string) (matched bool) {
5450

5551
// Has returns true if the input pattern has a wildcard (pattern).
5652
func Has(pattern string) bool {
57-
return cmp.Or(strings.Contains(pattern, "*"), strings.Contains(pattern, "?"))
53+
return strings.ContainsAny(pattern, "*?")
5854
}
5955

56+
// Keep one backtracking point instead of recursively exploring both
57+
// alternatives for every '*'.
6058
func deepMatchRune(str, pattern string, simple bool) bool {
61-
for len(pattern) > 0 {
62-
switch pattern[0] {
63-
default:
64-
if len(str) == 0 || str[0] != pattern[0] {
65-
return false
66-
}
67-
case '?':
68-
if len(str) == 0 {
69-
return simple
59+
var s, p int
60+
// Position of the '*' to resume from, and how much of str it has consumed.
61+
star, mark := -1, 0
62+
for s < len(str) || p < len(pattern) {
63+
if p < len(pattern) {
64+
switch pattern[p] {
65+
case '*':
66+
star, mark = p, s
67+
p++
68+
if p == len(pattern) {
69+
return true
70+
}
71+
if simple {
72+
// Before a later '*' replaces this one, check whether its
73+
// star-free segment can reach '?' with the name exhausted.
74+
for end := p; end < len(pattern) && pattern[end] != '*' && end-p <= len(str)-s; end++ {
75+
if pattern[end] == '?' && matchFixedSuffix(str[s:], pattern[p:end]) {
76+
return true
77+
}
78+
}
79+
}
80+
continue
81+
case '?':
82+
if simple && s == len(str) {
83+
return true
84+
}
85+
if s < len(str) {
86+
s++
87+
p++
88+
continue
89+
}
90+
default:
91+
if s < len(str) && pattern[p] == str[s] {
92+
s++
93+
p++
94+
continue
95+
}
7096
}
71-
case '*':
72-
return len(pattern) == 1 || // Pattern ends with this star
73-
deepMatchRune(str, pattern[1:], simple) || // Matches next part of pattern
74-
(len(str) > 0 && deepMatchRune(str[1:], pattern, simple)) // Continue searching forward
7597
}
76-
str = str[1:]
77-
pattern = pattern[1:]
98+
if star < 0 {
99+
return false
100+
}
101+
// Let the last '*' swallow one more byte and retry from there.
102+
mark++
103+
if mark > len(str) {
104+
return false
105+
}
106+
s, p = mark, star+1
107+
}
108+
return true
109+
}
110+
111+
// matchFixedSuffix checks a star-free pattern already known to fit in str.
112+
func matchFixedSuffix(str, pattern string) bool {
113+
str = str[len(str)-len(pattern):]
114+
for i := len(pattern) - 1; i >= 0; i-- {
115+
if pattern[i] != '?' && pattern[i] != str[i] {
116+
return false
117+
}
78118
}
79-
return len(str) == 0 && len(pattern) == 0
119+
return true
80120
}
81121

82122
// MatchAsPatternPrefix matches text as a prefix of the given pattern. Examples:

0 commit comments

Comments
 (0)