Skip to content

Commit 0175ca1

Browse files
committed
Added boolean policy to policy parser
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 5642052 commit 0175ca1

6 files changed

Lines changed: 244 additions & 20 deletions

File tree

.github/workflows/nightly-fuzz.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ jobs:
4848
- name: identity-marshal-decode-identity
4949
pkg: ./token/services/identity/marshal
5050
func: FuzzDecodeIdentityNoPanic
51+
- name: identity-boolpolicy-parse
52+
pkg: ./token/services/identity/boolpolicy
53+
func: FuzzParseNoPanic
5154
- name: identity-multisig-deserializer
5255
pkg: ./token/services/identity/multisig
5356
func: FuzzMultiIdentityDeserializeNoPanic

token/services/identity/boolpolicy/parser.go

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ SPDX-License-Identifier: Apache-2.0
1818
package boolpolicy
1919

2020
import (
21-
"errors"
2221
"fmt"
2322
"strconv"
2423
"strings"
2524
"unicode"
25+
26+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
2627
)
2728

2829
const (
@@ -36,6 +37,17 @@ const (
3637
// Go call stack; capping at 64 prevents goroutine stack exhaustion from
3738
// attacker-supplied deeply-nested expressions.
3839
maxParseDepth = 64
40+
41+
// maxParseNodes is the maximum number of AST nodes (RefNode/AndNode/OrNode)
42+
// that Parse will construct for a single expression. The depth cap bounds
43+
// the shape of the tree but not its size: a flat, unparenthesised chain such
44+
// as "$0 OR $1 OR ..." stays at depth 0 while producing one node per token.
45+
// Capping the total node count bounds the memory footprint of the AST — and
46+
// the work any later traversal performs — regardless of expression shape.
47+
// 1024 nodes comfortably covers real policies (hundreds of component
48+
// identities) while staying well under the ~2000-node worst case the
49+
// maxPolicyLen byte limit alone would otherwise permit.
50+
maxParseNodes = 1024
3951
)
4052

4153
// ---------------------------------------------------------------------------
@@ -140,11 +152,11 @@ func (l *lexer) next() (lexToken, error) {
140152
l.pos++
141153
}
142154
if l.pos == start {
143-
return lexToken{}, fmt.Errorf("expected digit after '$' at position %d", l.pos)
155+
return lexToken{}, errors.Errorf("expected digit after '$' at position %d", l.pos)
144156
}
145157
idx, err := strconv.Atoi(string(l.runes[start:l.pos]))
146158
if err != nil {
147-
return lexToken{}, fmt.Errorf("invalid index at position %d: %w", start, err)
159+
return lexToken{}, errors.Wrapf(err, "invalid index at position %d", start)
148160
}
149161

150162
return lexToken{kind: tokRef, index: idx}, nil
@@ -161,11 +173,11 @@ func (l *lexer) next() (lexToken, error) {
161173
case "OR":
162174
return lexToken{kind: tokOr}, nil
163175
default:
164-
return lexToken{}, fmt.Errorf("unknown keyword %q at position %d", word, start)
176+
return lexToken{}, errors.Errorf("unknown keyword %q at position %d", word, start)
165177
}
166178

167179
default:
168-
return lexToken{}, fmt.Errorf("unexpected character %q at position %d", string(ch), l.pos)
180+
return lexToken{}, errors.Errorf("unexpected character %q at position %d", string(ch), l.pos)
169181
}
170182
}
171183

@@ -178,19 +190,40 @@ type parser struct {
178190
lex *lexer
179191
current lexToken
180192
err error
193+
nodes int // number of AST nodes constructed so far
194+
}
195+
196+
// countNode records the construction of one AST node and returns whether the
197+
// parser is still within the maxParseNodes budget. On overflow it latches an
198+
// error (if one is not already set) so the caller can bail out immediately.
199+
func (p *parser) countNode() bool {
200+
p.nodes++
201+
if p.nodes > maxParseNodes {
202+
if p.err == nil {
203+
p.err = errors.Errorf("policy expression exceeds maximum node count of %d", maxParseNodes)
204+
}
205+
206+
return false
207+
}
208+
209+
return true
181210
}
182211

183212
// Parse parses a boolean expression string and returns the root AST node.
184213
// It returns an error for any lexical or syntactic problems.
185214
//
186-
// Parse enforces two hard limits to prevent resource exhaustion:
215+
// Parse enforces three hard limits to prevent resource exhaustion:
187216
// - input longer than maxPolicyLen bytes is rejected immediately.
188217
// - parenthesis nesting deeper than maxParseDepth levels is rejected;
189218
// this bounds the Go call-stack depth of the recursive descent and
190219
// prevents goroutine stack exhaustion from attacker-supplied input.
220+
// - expressions producing more than maxParseNodes AST nodes are rejected;
221+
// this bounds the size of the AST (and the cost of any later traversal)
222+
// independently of its shape, since the depth cap alone does not limit a
223+
// flat chain such as "$0 OR $1 OR ...".
191224
func Parse(input string) (Node, error) {
192225
if len(input) > maxPolicyLen {
193-
return nil, fmt.Errorf("policy expression exceeds maximum length of %d bytes (got %d)", maxPolicyLen, len(input))
226+
return nil, errors.Errorf("policy expression exceeds maximum length of %d bytes (got %d)", maxPolicyLen, len(input))
194227
}
195228

196229
p := &parser{lex: newLexer(input)}
@@ -224,6 +257,9 @@ func (p *parser) parseOr(depth int) Node {
224257
for p.err == nil && p.current.kind == tokOr {
225258
p.advance()
226259
right := p.parseAnd(depth)
260+
if !p.countNode() {
261+
return nil
262+
}
227263
left = &OrNode{Left: left, Right: right}
228264
}
229265

@@ -237,6 +273,9 @@ func (p *parser) parseAnd(depth int) Node {
237273
for p.err == nil && p.current.kind == tokAnd {
238274
p.advance()
239275
right := p.parsePrimary(depth)
276+
if !p.countNode() {
277+
return nil
278+
}
240279
left = &AndNode{Left: left, Right: right}
241280
}
242281

@@ -250,14 +289,17 @@ func (p *parser) parsePrimary(depth int) Node {
250289
}
251290
switch p.current.kind {
252291
case tokRef:
292+
if !p.countNode() {
293+
return nil
294+
}
253295
node := &RefNode{Index: p.current.index}
254296
p.advance()
255297

256298
return node
257299

258300
case tokLParen:
259301
if depth >= maxParseDepth {
260-
p.err = fmt.Errorf("policy expression exceeds maximum nesting depth of %d", maxParseDepth)
302+
p.err = errors.Errorf("policy expression exceeds maximum nesting depth of %d", maxParseDepth)
261303

262304
return nil
263305
}
@@ -267,7 +309,7 @@ func (p *parser) parsePrimary(depth int) Node {
267309
return nil
268310
}
269311
if p.current.kind != tokRParen {
270-
p.err = fmt.Errorf("expected ')' but got token kind %v", p.current.kind)
312+
p.err = errors.Errorf("expected ')' but got token kind %v", p.current.kind)
271313

272314
return nil
273315
}
@@ -276,7 +318,7 @@ func (p *parser) parsePrimary(depth int) Node {
276318
return node
277319

278320
default:
279-
p.err = fmt.Errorf("expected '$N' or '(' at position %d", p.lex.pos)
321+
p.err = errors.Errorf("expected '$N' or '(' at position %d", p.lex.pos)
280322

281323
return nil
282324
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package boolpolicy
8+
9+
import (
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// FuzzParseNoPanic hunts for policy strings that panic Parse instead of
16+
// returning a Node or an error. Parse is the entry point for
17+
// attacker-controllable policy expressions carried inside a PolicyIdentity, so
18+
// it must never panic and must always terminate within its resource caps
19+
// (length, nesting depth, and node count).
20+
func FuzzParseNoPanic(f *testing.F) {
21+
seeds := []string{
22+
"", // empty
23+
"$0", // single ref
24+
"$42", // multi-digit ref
25+
"$0 AND $1", // simple AND
26+
"$0 OR $1", // simple OR
27+
"$0 OR ($1 AND $2)", // nested
28+
"((($0)))", // parenthesised
29+
"$0 OR $0 OR $0 OR $0", // repeated refs (exercises memoisation callers)
30+
"$", // dangling dollar
31+
"$0 NOT $1", // unknown keyword
32+
"($0 AND $1", // unmatched open paren
33+
"$0 AND $1)", // unmatched close paren
34+
"$0 AND", // missing operand
35+
"$0 & $1", // unexpected character
36+
"$99999999999999999999", // index overflows int
37+
"(((((((((((((((((((((", // deeply nested opens
38+
"$0 OR $1 OR $2 OR $3 OR", // trailing operator
39+
}
40+
for _, s := range seeds {
41+
f.Add(s)
42+
}
43+
44+
f.Fuzz(func(t *testing.T, input string) {
45+
require.NotPanics(t, func() {
46+
node, err := Parse(input)
47+
// A successful parse must yield a non-nil node; an error must yield
48+
// a nil node. Neither may panic regardless of input.
49+
if err == nil {
50+
require.NotNil(t, node)
51+
}
52+
})
53+
})
54+
}

token/services/identity/boolpolicy/parser_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,44 @@ func TestErrorNestingTooDeep(t *testing.T) {
299299
assert.Contains(t, err.Error(), "exceeds maximum nesting depth")
300300
}
301301

302+
// orChain builds a flat, unparenthesised OR chain of `refs` references using
303+
// the compact "$0OR$0OR..." form (no surrounding spaces), so the byte length
304+
// stays minimal while the node count grows. The result has `refs` RefNodes and
305+
// `refs-1` OrNodes, i.e. 2*refs-1 AST nodes, all at nesting depth 0.
306+
func orChain(refs int) string {
307+
var sb strings.Builder
308+
sb.WriteString("$0")
309+
for i := 1; i < refs; i++ {
310+
sb.WriteString("OR$0")
311+
}
312+
313+
return sb.String()
314+
}
315+
316+
func TestErrorTooManyNodes(t *testing.T) {
317+
// A flat OR chain stays at nesting depth 0 but produces one node per ref
318+
// plus one per operator, so it exercises the node cap rather than the depth
319+
// cap. With R refs the chain yields 2R-1 nodes; pick R large enough to
320+
// exceed maxParseNodes while the string stays under maxPolicyLen.
321+
refs := maxParseNodes // 2*maxParseNodes-1 nodes, comfortably over the cap
322+
policy := orChain(refs)
323+
require.LessOrEqual(t, len(policy), maxPolicyLen, "test input must stay within the length cap")
324+
325+
_, err := Parse(policy)
326+
require.Error(t, err)
327+
assert.Contains(t, err.Error(), "exceeds maximum node count")
328+
}
329+
330+
func TestNodeCountAtLimitIsAllowed(t *testing.T) {
331+
// Build an OR chain whose node count lands just at/under maxParseNodes: with
332+
// R refs the node count is 2R-1, so R = (maxParseNodes+1)/2 hits the cap
333+
// exactly when maxParseNodes is odd and lands just under it when even —
334+
// either way it must parse without error.
335+
refs := (maxParseNodes + 1) / 2
336+
_, err := Parse(orChain(refs))
337+
require.NoError(t, err)
338+
}
339+
302340
func TestNestingAtLimitIsAllowed(t *testing.T) {
303341
// A policy nested exactly at maxParseDepth must succeed.
304342
policy := ""

token/services/identity/boolpolicy/sig.go

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,31 +81,62 @@ func (v *PolicyVerifier) Verify(msg, sigBytes []byte) error {
8181
return errors.Errorf("policy signature has [%d] slots, expected [%d]",
8282
len(sig.Signatures), len(v.Verifiers))
8383
}
84-
if !v.evalNode(v.Policy, msg, sig.Signatures) {
84+
// memo caches each index's verification result for the duration of this
85+
// Verify call: a $N reference is checked against the same (msg, sigs[N])
86+
// no matter how many times it appears in the policy, so the potentially
87+
// expensive Verifiers[N].Verify is invoked at most once per index.
88+
memo := make([]refResult, len(sig.Signatures))
89+
if !v.evalNode(v.Policy, msg, sig.Signatures, memo) {
8590
return errors.New("policy not satisfied")
8691
}
8792

8893
return nil
8994
}
9095

96+
// refResult is the memoised outcome of verifying a single component index.
97+
type refResult int8
98+
99+
const (
100+
refUnknown refResult = iota // not yet verified in this Verify call
101+
refPass // verification succeeded
102+
refFail // verification failed (absent slot or bad signature)
103+
)
104+
91105
// evalNode recursively evaluates the policy AST against the provided signatures.
92-
func (v *PolicyVerifier) evalNode(node Node, msg []byte, sigs [][]byte) bool {
106+
// memo is indexed by component index ($N) and caches per-index verification
107+
// results across the whole traversal; it must have one entry per signature slot.
108+
func (v *PolicyVerifier) evalNode(node Node, msg []byte, sigs [][]byte, memo []refResult) bool {
93109
switch n := node.(type) {
94110
case *RefNode:
95-
i := n.Index
96-
if i < 0 || i >= len(sigs) || len(sigs[i]) == 0 {
97-
return false
98-
}
99-
100-
return v.Verifiers[i].Verify(msg, sigs[i]) == nil
111+
return v.evalRef(n.Index, msg, sigs, memo)
101112

102113
case *AndNode:
103-
return v.evalNode(n.Left, msg, sigs) && v.evalNode(n.Right, msg, sigs)
114+
return v.evalNode(n.Left, msg, sigs, memo) && v.evalNode(n.Right, msg, sigs, memo)
104115

105116
case *OrNode:
106-
return v.evalNode(n.Left, msg, sigs) || v.evalNode(n.Right, msg, sigs)
117+
return v.evalNode(n.Left, msg, sigs, memo) || v.evalNode(n.Right, msg, sigs, memo)
107118

108119
default:
109120
return false
110121
}
111122
}
123+
124+
// evalRef verifies the component identity at index i, reusing a previously
125+
// computed result for the same index when one is available.
126+
func (v *PolicyVerifier) evalRef(i int, msg []byte, sigs [][]byte, memo []refResult) bool {
127+
if i < 0 || i >= len(sigs) || len(sigs[i]) == 0 {
128+
return false
129+
}
130+
if memo[i] != refUnknown {
131+
return memo[i] == refPass
132+
}
133+
134+
ok := v.Verifiers[i].Verify(msg, sigs[i]) == nil
135+
if ok {
136+
memo[i] = refPass
137+
} else {
138+
memo[i] = refFail
139+
}
140+
141+
return ok
142+
}

0 commit comments

Comments
 (0)