Skip to content

Commit 4599354

Browse files
committed
fix: introduce gRPC input validation
Validate that both label & ID queries have sane limits on inputs. For label query - ensure that values are populated, for the ID query ensure a maximum budget for the regexp to be compiled. In real life ID queries should be simple (and probably regexp is an overkill here), so limit the regexp maximum complexity. Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
1 parent c7cf050 commit 4599354

2 files changed

Lines changed: 257 additions & 0 deletions

File tree

pkg/state/protobuf/server/helpers.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package server
66

77
import (
88
"regexp"
9+
"regexp/syntax"
910

1011
"google.golang.org/grpc/codes"
1112
"google.golang.org/grpc/status"
@@ -25,6 +26,19 @@ func ConvertLabelQuery(terms []*v1alpha1.LabelTerm) ([]resource.LabelQueryOption
2526
opts = append(opts, resource.NotMatches)
2627
}
2728

29+
// Value is a repeated field in the protobuf definition, but the operators below
30+
// require a single value, so reject the request instead of panicking on an empty one.
31+
switch term.Op { //nolint:exhaustive
32+
case v1alpha1.LabelTerm_EQUAL,
33+
v1alpha1.LabelTerm_LT,
34+
v1alpha1.LabelTerm_LTE,
35+
v1alpha1.LabelTerm_LT_NUMERIC,
36+
v1alpha1.LabelTerm_LTE_NUMERIC:
37+
if len(term.Value) == 0 {
38+
return nil, status.Errorf(codes.InvalidArgument, "missing value for label query operator: %v", term.Op)
39+
}
40+
}
41+
2842
switch term.Op {
2943
case v1alpha1.LabelTerm_EQUAL:
3044
labelOpts = append(labelOpts, resource.LabelEqual(term.Key, term.Value[0], opts...))
@@ -50,16 +64,95 @@ func ConvertLabelQuery(terms []*v1alpha1.LabelTerm) ([]resource.LabelQueryOption
5064
return labelOpts, nil
5165
}
5266

67+
const (
68+
// maxRegexpLength is the maximum length of the ID query regexp.
69+
//
70+
// Parsing cost is roughly linear in the length of the pattern, so this bounds the
71+
// work done before the program size below can be estimated.
72+
maxRegexpLength = 4096
73+
74+
// maxRegexpProgramSize is the maximum size (in instructions) of the compiled ID query regexp.
75+
//
76+
// Repetition expands a pattern far beyond its length: the parser bounds the expansion
77+
// only by its own ~3.3M instruction limit, so the length limit above is not by itself a
78+
// useful bound on the compiled program. Reaching that ceiling costs ~700MiB and a few
79+
// hundred milliseconds per query.
80+
maxRegexpProgramSize = 4096
81+
)
82+
5383
// ConvertIDQuery converts protobuf representation of IDQuery to state representation.
5484
func ConvertIDQuery(input *v1alpha1.IDQuery) ([]resource.IDQueryOption, error) {
5585
if input == nil || input.Regexp == "" {
5686
return nil, nil
5787
}
5888

89+
if len(input.Regexp) > maxRegexpLength {
90+
return nil, status.Errorf(codes.InvalidArgument, "regexp is too long: %d > %d", len(input.Regexp), maxRegexpLength)
91+
}
92+
93+
parsed, err := syntax.Parse(input.Regexp, syntax.Perl)
94+
if err != nil {
95+
return nil, status.Errorf(codes.InvalidArgument, "failed to parse regexp: %v", err)
96+
}
97+
98+
if size := regexpProgramSize(parsed); size > maxRegexpProgramSize {
99+
return nil, status.Errorf(codes.InvalidArgument, "regexp is too complex: %d > %d", size, maxRegexpProgramSize)
100+
}
101+
59102
re, err := regexp.Compile(input.Regexp)
60103
if err != nil {
61104
return nil, status.Errorf(codes.InvalidArgument, "failed to compile regexp: %v", err)
62105
}
63106

64107
return []resource.IDQueryOption{resource.IDRegexpMatch(re)}, nil
65108
}
109+
110+
// regexpProgramSize estimates the number of instructions the parsed regexp compiles to.
111+
//
112+
// This is a port of (*parser).calcSize from regexp/syntax, which the parser itself uses to
113+
// enforce its (much higher) built-in limit. It is accurate to within the couple of extra
114+
// instructions the compiler emits for the program as a whole, and unlike compiling the
115+
// regexp it doesn't allocate the program. Recursion is bounded by the nesting depth limit
116+
// enforced by the parser.
117+
func regexpProgramSize(re *syntax.Regexp) int64 {
118+
var size int64
119+
120+
switch re.Op { //nolint:exhaustive
121+
case syntax.OpLiteral:
122+
size = int64(len(re.Rune))
123+
case syntax.OpCapture, syntax.OpStar:
124+
// star can be 1+ or 2+; assume 2 pessimistically
125+
size = 2 + regexpProgramSize(re.Sub[0])
126+
case syntax.OpPlus, syntax.OpQuest:
127+
size = 1 + regexpProgramSize(re.Sub[0])
128+
case syntax.OpConcat:
129+
for _, sub := range re.Sub {
130+
size += regexpProgramSize(sub)
131+
}
132+
case syntax.OpAlternate:
133+
for _, sub := range re.Sub {
134+
size += regexpProgramSize(sub)
135+
}
136+
137+
if len(re.Sub) > 1 {
138+
size += int64(len(re.Sub)) - 1
139+
}
140+
case syntax.OpRepeat:
141+
sub := regexpProgramSize(re.Sub[0])
142+
143+
if re.Max == -1 {
144+
if re.Min == 0 {
145+
size = 2 + sub // x*
146+
} else {
147+
size = 1 + int64(re.Min)*sub // xxx+
148+
}
149+
150+
break
151+
}
152+
153+
// x{2,5} = xx(x(x(x)?)?)?
154+
size = int64(re.Max)*sub + int64(re.Max-re.Min)
155+
}
156+
157+
return max(1, size)
158+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
package server_test
6+
7+
import (
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"google.golang.org/grpc/codes"
14+
"google.golang.org/grpc/status"
15+
16+
"github.com/cosi-project/runtime/api/v1alpha1"
17+
"github.com/cosi-project/runtime/pkg/state/protobuf/server"
18+
)
19+
20+
func TestConvertIDQuery(t *testing.T) {
21+
t.Parallel()
22+
23+
for _, test := range []struct {
24+
name string
25+
regexp string
26+
expectedErr string
27+
expectedNil bool
28+
}{
29+
{
30+
name: "empty",
31+
regexp: "",
32+
expectedNil: true,
33+
},
34+
{
35+
name: "simple",
36+
regexp: "^foo-[0-9]+$",
37+
},
38+
{
39+
name: "invalid",
40+
regexp: "^foo-[0-9+$",
41+
expectedErr: "rpc error: code = InvalidArgument desc = failed to parse regexp: error parsing regexp: missing closing ]: `[0-9+$`",
42+
},
43+
{
44+
name: "too long",
45+
regexp: strings.Repeat("a", 4097),
46+
expectedErr: "rpc error: code = InvalidArgument desc = regexp is too long: 4097 > 4096",
47+
},
48+
{
49+
// short pattern, but expands to ~1M instructions and ~200MiB when compiled
50+
name: "too complex",
51+
regexp: "(" + strings.Repeat("a", 1024) + "){1000}",
52+
expectedErr: "rpc error: code = InvalidArgument desc = regexp is too complex: 1026000 > 4096",
53+
},
54+
{
55+
name: "too complex, nested repeats",
56+
regexp: "((abcdefghij){10}){100}",
57+
expectedErr: "rpc error: code = InvalidArgument desc = regexp is too complex: 12200 > 4096",
58+
},
59+
{
60+
name: "just under the complexity limit",
61+
regexp: "(" + strings.Repeat("a", 7) + "){450}",
62+
},
63+
} {
64+
t.Run(test.name, func(t *testing.T) {
65+
t.Parallel()
66+
67+
opts, err := server.ConvertIDQuery(&v1alpha1.IDQuery{Regexp: test.regexp})
68+
69+
if test.expectedErr != "" {
70+
require.Error(t, err)
71+
assert.Equal(t, test.expectedErr, err.Error())
72+
assert.Nil(t, opts)
73+
74+
return
75+
}
76+
77+
require.NoError(t, err)
78+
79+
if test.expectedNil {
80+
assert.Nil(t, opts)
81+
} else {
82+
assert.Len(t, opts, 1)
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestConvertIDQueryNil(t *testing.T) {
89+
t.Parallel()
90+
91+
opts, err := server.ConvertIDQuery(nil)
92+
require.NoError(t, err)
93+
assert.Nil(t, opts)
94+
}
95+
96+
func TestConvertLabelQueryMissingValue(t *testing.T) {
97+
t.Parallel()
98+
99+
for _, op := range []v1alpha1.LabelTerm_Operation{
100+
v1alpha1.LabelTerm_EQUAL,
101+
v1alpha1.LabelTerm_LT,
102+
v1alpha1.LabelTerm_LTE,
103+
v1alpha1.LabelTerm_LT_NUMERIC,
104+
v1alpha1.LabelTerm_LTE_NUMERIC,
105+
} {
106+
t.Run(op.String(), func(t *testing.T) {
107+
t.Parallel()
108+
109+
opts, err := server.ConvertLabelQuery([]*v1alpha1.LabelTerm{{Op: op, Key: "key"}})
110+
require.Error(t, err)
111+
assert.Equal(t, "rpc error: code = InvalidArgument desc = missing value for label query operator: "+op.String(), err.Error())
112+
assert.Nil(t, opts)
113+
})
114+
}
115+
}
116+
117+
func TestConvertLabelQuery(t *testing.T) {
118+
t.Parallel()
119+
120+
// operators which don't require a value
121+
for _, op := range []v1alpha1.LabelTerm_Operation{
122+
v1alpha1.LabelTerm_EXISTS,
123+
v1alpha1.LabelTerm_NOT_EXISTS,
124+
v1alpha1.LabelTerm_IN,
125+
} {
126+
t.Run(op.String(), func(t *testing.T) {
127+
t.Parallel()
128+
129+
opts, err := server.ConvertLabelQuery([]*v1alpha1.LabelTerm{{Op: op, Key: "key"}})
130+
require.NoError(t, err)
131+
assert.Len(t, opts, 1)
132+
})
133+
}
134+
135+
t.Run("with values", func(t *testing.T) {
136+
t.Parallel()
137+
138+
opts, err := server.ConvertLabelQuery([]*v1alpha1.LabelTerm{
139+
{Op: v1alpha1.LabelTerm_EQUAL, Key: "key", Value: []string{"value"}},
140+
{Op: v1alpha1.LabelTerm_LTE_NUMERIC, Key: "key", Value: []string{"5"}, Invert: true},
141+
})
142+
require.NoError(t, err)
143+
assert.Len(t, opts, 2)
144+
})
145+
146+
t.Run("missing values", func(t *testing.T) {
147+
t.Parallel()
148+
149+
_, err := server.ConvertLabelQuery([]*v1alpha1.LabelTerm{
150+
{Op: v1alpha1.LabelTerm_EQUAL, Key: "key"},
151+
})
152+
require.Error(t, err)
153+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
154+
})
155+
156+
t.Run("unsupported operator", func(t *testing.T) {
157+
t.Parallel()
158+
159+
opts, err := server.ConvertLabelQuery([]*v1alpha1.LabelTerm{{Op: v1alpha1.LabelTerm_Operation(100), Key: "key"}})
160+
require.Error(t, err)
161+
assert.Equal(t, "rpc error: code = Unimplemented desc = unsupported label query operator: 100", err.Error())
162+
assert.Nil(t, opts)
163+
})
164+
}

0 commit comments

Comments
 (0)