Skip to content

Commit a3e45a0

Browse files
committed
fix(regex_redos): don't flag ? quantifier as nested quantifier vulnerability
The ? quantifier (0 or 1 times) cannot cause exponential backtracking when used as the outer quantifier because it matches at most once. Only unbounded quantifiers (+, *, {n,m} where m>1) should be flagged. This fixes false positives for patterns like: - ^/([_0-9a-zA-Z-]+/)?core/cache/... - ^/([a-z]+/)?foo/bar The change modifies the condition from checking if max > min (which is true for ?) to checking if max > 1 (which excludes ?). Fixes false positives reported in user configs with WP Rocket caching rules that use optional prefix patterns.
1 parent 7656517 commit a3e45a0

File tree

3 files changed

+61
-7
lines changed

3 files changed

+61
-7
lines changed

gixy/plugins/regex_redos.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,15 @@ def _check_nested_quantifiers(self, parsed, depth, in_quantifier):
125125
if op in QUANTIFIERS:
126126
min_repeat, max_repeat, subpattern = av
127127

128-
# Check if this quantifier can match variable length
129-
can_repeat = (
130-
max_repeat > min_repeat or max_repeat == sre_parse.MAXREPEAT
128+
# Check if this quantifier can match MULTIPLE times (more than once)
129+
# The ? quantifier (max=1) cannot cause exponential backtracking as
130+
# the outer quantifier because it matches at most once.
131+
# Only unbounded quantifiers (+, *, {n,m} where m>1) are dangerous.
132+
can_repeat_multiple = (
133+
max_repeat > 1 or max_repeat == sre_parse.MAXREPEAT
131134
)
132135

133-
if can_repeat:
136+
if can_repeat_multiple:
134137
if in_quantifier:
135138
# Found nested quantifier!
136139
self.vulnerabilities.append(
@@ -158,7 +161,11 @@ def _check_nested_quantifiers(self, parsed, depth, in_quantifier):
158161
subpattern, depth + 1, in_quantifier=True
159162
)
160163
else:
161-
self._check_nested_quantifiers(subpattern, depth + 1, in_quantifier)
164+
# Bounded quantifiers like ? (max=1) or {1,2} don't propagate
165+
# the in_quantifier context since they can't cause exponential backtracking
166+
self._check_nested_quantifiers(
167+
subpattern, depth + 1, in_quantifier=False
168+
)
162169

163170
elif op == SUBPATTERN:
164171
_, _, _, subpattern = av
@@ -175,11 +182,13 @@ def _check_nested_quantifiers(self, parsed, depth, in_quantifier):
175182
self._check_nested_quantifiers(subpattern, depth + 1, in_quantifier)
176183

177184
def _contains_quantifier(self, parsed):
178-
"""Check if parsed pattern contains any unbounded quantifiers."""
185+
"""Check if parsed pattern contains any unbounded quantifiers (can match more than once)."""
179186
for op, av in parsed:
180187
if op in QUANTIFIERS:
181188
min_repeat, max_repeat, _ = av
182-
if max_repeat > min_repeat or max_repeat == sre_parse.MAXREPEAT:
189+
# Only unbounded quantifiers (+, *, {n,m} where m>1) count as dangerous
190+
# The ? quantifier (max=1) is bounded and not dangerous for nesting
191+
if max_repeat > 1 or max_repeat == sre_parse.MAXREPEAT:
183192
return True
184193
elif op == SUBPATTERN:
185194
_, _, _, subpattern = av
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# fp: The ? quantifier (optional group) should not trigger nested quantifier detection
2+
# since it can only match 0 or 1 times - no exponential backtracking possible
3+
4+
server {
5+
location ~ ^/([_0-9a-zA-Z-]+/)?core/cache/busting/(.*)$ {
6+
# This pattern has:
7+
# - Outer ? (0 or 1 times) - bounded, not dangerous
8+
# - Inner + on char class
9+
# Not a nested quantifier vulnerability!
10+
return 200;
11+
}
12+
}

tests/plugins/test_redos_analyzer.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,39 @@ def test_real_world_safe_pattern(self):
141141
vulns = analyzer.analyze()
142142
assert len(vulns) == 0
143143

144+
def test_optional_group_with_inner_quantifier(self):
145+
"""([a-z]+/)? is safe - ? means 0 or 1 times, no exponential backtracking."""
146+
analyzer = RedosAnalyzer("^/([a-z]+/)?foo$")
147+
vulns = analyzer.analyze()
148+
assert len(vulns) == 0
149+
150+
def test_optional_group_real_world(self):
151+
"""Real-world cache busting pattern should be safe."""
152+
analyzer = RedosAnalyzer("^/([_0-9a-zA-Z-]+/)?core/cache/busting/1/core/(.*)")
153+
vulns = analyzer.analyze()
154+
assert len(vulns) == 0
155+
156+
def test_optional_group_complex(self):
157+
"""Complex optional group should be safe."""
158+
analyzer = RedosAnalyzer("^/(prefix-[a-z0-9]+)?/api/v[0-9]+$")
159+
vulns = analyzer.analyze()
160+
assert len(vulns) == 0
161+
162+
def test_bounded_outer_with_unbounded_inner(self):
163+
"""(a+){0,1} is same as (a+)? - bounded outer, should be safe."""
164+
analyzer = RedosAnalyzer("(a+){0,1}")
165+
vulns = analyzer.analyze()
166+
assert len(vulns) == 0
167+
168+
def test_bounded_outer_max_2(self):
169+
"""(a+){1,2} has bounded outer max=2, still could have some backtracking."""
170+
# This is a borderline case - max=2 means at most 2 repetitions
171+
# Not exponential but could flag as nested
172+
analyzer = RedosAnalyzer("(a+){1,2}")
173+
vulns = analyzer.analyze()
174+
# We allow max>1 to be flagged, so this should be flagged
175+
assert len(vulns) > 0
176+
144177

145178
class TestRealWorldVulnerablePatterns:
146179
"""Test detection of real-world vulnerable patterns."""

0 commit comments

Comments
 (0)