Skip to content

Commit 6580bf8

Browse files
Refactor regex pattern validation to use timeout-based approach (#2090)
- Remove isPathologicalRegex function and replace with MatchTimeout - Simplify parseFilter by relying on runtime timeout protection - Add comprehensive timeout test for pathological regex patterns - Set 5-second timeout for all compiled regex operations
1 parent 0194360 commit 6580bf8

File tree

2 files changed

+43
-152
lines changed

2 files changed

+43
-152
lines changed

pkg/zabbix/utils.go

Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -66,51 +66,6 @@ func splitKeyParams(paramStr string) []string {
6666
return params
6767
}
6868

69-
// isPathologicalRegex detects potentially dangerous regex patterns that could cause ReDoS
70-
func isPathologicalRegex(pattern string) bool {
71-
// Check for consecutive quantifiers
72-
consecutiveQuantifiers := []string{`\*\*`, `\+\+`, `\*\+`, `\+\*`}
73-
for _, q := range consecutiveQuantifiers {
74-
if matched, _ := regexp.MatchString(q, pattern); matched {
75-
return true
76-
}
77-
}
78-
79-
// Check for nested quantifiers
80-
nestedQuantifiers := []string{
81-
`\([^)]*\+[^)]*\)\+`, // (a+)+
82-
`\([^)]*\*[^)]*\)\*`, // (a*)*
83-
`\([^)]*\+[^)]*\)\*`, // (a+)*
84-
`\([^)]*\*[^)]*\)\+`, // (a*)+
85-
}
86-
for _, nested := range nestedQuantifiers {
87-
if matched, _ := regexp.MatchString(nested, pattern); matched {
88-
return true
89-
}
90-
}
91-
92-
// Check for specific catastrophic patterns
93-
catastrophicPatterns := []string{
94-
`\(\.\*\)\*`, // (.*)*
95-
`\(\.\+\)\+`, // (.+)+
96-
`\(\.\*\)\+`, // (.*)+
97-
`\(\.\+\)\*`, // (.+)*
98-
}
99-
for _, catastrophic := range catastrophicPatterns {
100-
if matched, _ := regexp.MatchString(catastrophic, pattern); matched {
101-
return true
102-
}
103-
}
104-
105-
// Check for obvious overlapping alternation (manual check for exact duplicates)
106-
if strings.Contains(pattern, "(a|a)") ||
107-
strings.Contains(pattern, "(1|1)") ||
108-
strings.Contains(pattern, "(.*|.*)") {
109-
return true
110-
}
111-
112-
return false
113-
}
11469

11570
// safeRegexpCompile compiles a regex with timeout protection
11671
func safeRegexpCompile(pattern string) (*regexp2.Regexp, error) {
@@ -149,11 +104,6 @@ func parseFilter(filter string) (*regexp2.Regexp, error) {
149104
}
150105

151106
regexPattern := matches[1]
152-
153-
// Security: Check for pathological regex patterns
154-
if isPathologicalRegex(regexPattern) {
155-
return nil, backend.DownstreamErrorf("error parsing regexp: potentially dangerous regex pattern detected")
156-
}
157107

158108
pattern := ""
159109
if matches[2] != "" {
@@ -165,12 +115,15 @@ func parseFilter(filter string) (*regexp2.Regexp, error) {
165115
}
166116
pattern += regexPattern
167117

168-
// Security: Test compilation with timeout
118+
// Security: Compile regex with timeout protection
169119
compiled, err := safeRegexpCompile(pattern)
170120
if err != nil {
171121
return nil, backend.DownstreamErrorf("error parsing regexp: %v", err)
172122
}
173123

124+
// Set match timeout for runtime DoS protection (5 second timeout)
125+
compiled.MatchTimeout = 5 * time.Second
126+
174127
return compiled, nil
175128
}
176129

pkg/zabbix/utils_test.go

Lines changed: 39 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package zabbix
22

33
import (
4-
"reflect"
54
"testing"
5+
"time"
66

7-
"github.com/dlclark/regexp2"
87
"github.com/stretchr/testify/assert"
98
)
109

@@ -63,70 +62,42 @@ func TestParseFilter(t *testing.T) {
6362
tests := []struct {
6463
name string
6564
filter string
66-
want *regexp2.Regexp
65+
wantPattern string
6766
expectNoError bool
6867
expectedError string
6968
}{
7069
{
7170
name: "Simple regexp",
7271
filter: "/.*/",
73-
want: regexp2.MustCompile(".*", regexp2.RE2),
72+
wantPattern: ".*",
7473
expectNoError: true,
7574
expectedError: "",
7675
},
7776
{
7877
name: "Not a regex",
7978
filter: "/var/lib/mysql: Total space",
80-
want: nil,
79+
wantPattern: "",
8180
expectNoError: true,
8281
expectedError: "",
8382
},
8483
{
8584
name: "Regexp with modifier",
8685
filter: "/.*/i",
87-
want: regexp2.MustCompile("(?i).*", regexp2.RE2),
86+
wantPattern: "(?i).*",
8887
expectNoError: true,
8988
expectedError: "",
9089
},
9190
{
9291
name: "Regexp with unsupported modifier",
9392
filter: "/.*/1",
94-
want: nil,
93+
wantPattern: "",
9594
expectNoError: false,
9695
expectedError: "",
9796
},
98-
{
99-
name: "Pathological regex - nested quantifiers (a+)+",
100-
filter: "/^(a+)+$/",
101-
want: nil,
102-
expectNoError: false,
103-
expectedError: "error parsing regexp: potentially dangerous regex pattern detected",
104-
},
105-
{
106-
name: "Pathological regex - (.*)* pattern",
107-
filter: "/(.*)*/",
108-
want: nil,
109-
expectNoError: false,
110-
expectedError: "error parsing regexp: potentially dangerous regex pattern detected",
111-
},
112-
{
113-
name: "Pathological regex - overlapping alternation",
114-
filter: "/(a|a)*/",
115-
want: nil,
116-
expectNoError: false,
117-
expectedError: "error parsing regexp: potentially dangerous regex pattern detected",
118-
},
119-
{
120-
name: "Pathological regex - consecutive quantifiers",
121-
filter: "/a**/",
122-
want: nil,
123-
expectNoError: false,
124-
expectedError: "error parsing regexp: potentially dangerous regex pattern detected",
125-
},
12697
{
12798
name: "Safe complex regex",
12899
filter: "/^[a-zA-Z0-9_-]+\\.[a-zA-Z]{2,}$/",
129-
want: regexp2.MustCompile("^[a-zA-Z0-9_-]+\\.[a-zA-Z]{2,}$", regexp2.RE2),
100+
wantPattern: "^[a-zA-Z0-9_-]+\\.[a-zA-Z]{2,}$",
130101
expectNoError: true,
131102
expectedError: "",
132103
},
@@ -142,75 +113,42 @@ func TestParseFilter(t *testing.T) {
142113
assert.Error(t, err)
143114
assert.EqualError(t, err, tt.expectedError)
144115
}
145-
if !reflect.DeepEqual(got, tt.want) {
146-
t.Errorf("parseFilter() = %v, want %v", got, tt.want)
116+
if tt.wantPattern == "" {
117+
assert.Nil(t, got)
118+
} else {
119+
assert.NotNil(t, got)
120+
assert.Equal(t, tt.wantPattern, got.String())
121+
// Verify that timeout is set for DoS protection
122+
assert.Equal(t, 5*time.Second, got.MatchTimeout)
147123
}
148124
})
149125
}
150126
}
151127

152-
func TestIsPathologicalRegex(t *testing.T) {
153-
tests := []struct {
154-
name string
155-
pattern string
156-
expected bool
157-
}{
158-
{
159-
name: "Safe pattern",
160-
pattern: "^[a-zA-Z0-9]+$",
161-
expected: false,
162-
},
163-
{
164-
name: "Nested quantifiers (a+)+",
165-
pattern: "^(a+)+$",
166-
expected: true,
167-
},
168-
{
169-
name: "Nested quantifiers (a*)*",
170-
pattern: "(a*)*",
171-
expected: true,
172-
},
173-
{
174-
name: "Overlapping alternation (a|a)*",
175-
pattern: "(a|a)*",
176-
expected: true,
177-
},
178-
{
179-
name: "Consecutive quantifiers **",
180-
pattern: "a**",
181-
expected: true,
182-
},
183-
{
184-
name: "Consecutive quantifiers ++",
185-
pattern: "a++",
186-
expected: true,
187-
},
188-
{
189-
name: "Catastrophic (.*)* pattern",
190-
pattern: "(.*)*",
191-
expected: true,
192-
},
193-
{
194-
name: "Catastrophic (.+)+ pattern",
195-
pattern: "(.+)+",
196-
expected: true,
197-
},
198-
{
199-
name: "Safe alternation with different patterns",
200-
pattern: "(cat|dog)*",
201-
expected: false,
202-
},
203-
{
204-
name: "Safe quantifier usage",
205-
pattern: "[0-9]+\\.[0-9]*",
206-
expected: false,
207-
},
208-
}
209-
210-
for _, tt := range tests {
211-
t.Run(tt.name, func(t *testing.T) {
212-
result := isPathologicalRegex(tt.pattern)
213-
assert.Equal(t, tt.expected, result, "Pattern: %s", tt.pattern)
214-
})
128+
func TestParseFilterTimeout(t *testing.T) {
129+
// Test with a pathological regex pattern that should trigger MatchTimeout
130+
filter := "/((((.*)*)*)*)*z/"
131+
compiled, err := parseFilter(filter)
132+
133+
// The regex should compile successfully with timeout protection
134+
assert.NoError(t, err)
135+
assert.NotNil(t, compiled)
136+
assert.Equal(t, 5*time.Second, compiled.MatchTimeout)
137+
138+
// Test that the regex times out when matching against a problematic string
139+
// This string is crafted to trigger catastrophic backtracking
140+
testString := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 52 'a's, no 'z'
141+
142+
// The match should timeout and return an error
143+
match, err := compiled.MatchString(testString)
144+
145+
// We expect either a timeout error or the match to complete quickly if RE2 optimizations prevent catastrophic backtracking
146+
// In either case, the system should remain responsive
147+
assert.False(t, match) // Should not match since there's no 'z'
148+
149+
// If there's an error, it should be related to timeout
150+
if err != nil {
151+
assert.Contains(t, err.Error(), "timeout")
215152
}
216153
}
154+

0 commit comments

Comments
 (0)