1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
15 const Q: &str = r"(?:[+*?]|\{\d+,\})";
17 Regex::new(
20 &[
21 r"\([!-~]{1,60}[+*?]\)[+*]",
23 r"|\([!-~]{1,60}[+*?]\)\{\d+,\}",
25 r"|\([!-~]{1,60}\{\d+,\}\)[+*]",
27 r"|\([!-~]{1,60}\{\d+,\}\)\{\d+,\}",
28 r"|\([!-~]{0,60}(?:\|\.|\.\|)[!-~]{0,60}\)",
31 Q,
32 r"|\(\\[dwsDWS]\|(?:\\[dwsDWS]|[A-Za-z0-9_])\)",
35 Q,
36 r"|\((?:[!-~]{0,60}\||\|[!-~]{0,60})\)",
38 Q,
39 ]
40 .concat(),
41 )
42 .unwrap()
43});
44
45fn repeated_prefix_branch(input: &str) -> Option<(usize, usize)> {
49 let last_close = input.rfind(')')?;
54 let mut next_close = input.find(')')?;
55 for (open, _) in input.match_indices('(') {
56 if open > last_close {
57 break;
58 }
59 if open > next_close {
60 next_close = open + input[open..].find(')')?;
61 }
62 let close = next_close;
63 let tail = &input[close + 1..];
65 let quant_len = match tail.chars().next() {
66 Some('*' | '+') => 1,
67 Some('{') => match tail.find('}') {
68 Some(e) if tail[..e].contains(',') => e + 1,
69 _ => 0,
70 },
71 _ => continue,
72 };
73 if quant_len == 0 {
74 continue;
75 }
76 let mut branches = input[open + 1..close].split('|');
77 let Some(first) = branches.next() else {
79 continue;
80 };
81 let mut fc = first.chars();
82 let (Some(c), None) = (fc.next(), fc.next()) else {
83 continue;
84 };
85 if c.is_ascii_alphanumeric() && branches.any(|b| b.starts_with(c)) {
86 return Some((open, quant_len));
87 }
88 }
89 None
90}
91
92pub struct ReDoSDetector;
93
94impl Detector for ReDoSDetector {
95 fn name(&self) -> &'static str {
96 "redos"
97 }
98
99 fn detect(&self, input: &str) -> Option<DetectionResult> {
100 if let Some((open, quant_len)) = repeated_prefix_branch(input) {
101 let end = input[open..]
102 .find(')')
103 .map_or(input.len(), |e| open + e + 1 + quant_len);
104 return Some(DetectionResult {
105 attack_type: self.name().to_string(),
106 category: AttackCategory::Data,
107 severity: Severity::Medium,
108 matched_pattern: input[open..end].to_string(),
109 offset: open,
110 message: "Catastrophic backtracking pattern detected".into(),
111 });
112 }
113 regex_detect(
114 std::slice::from_ref(&*PATTERNS),
115 self.name(),
116 AttackCategory::Data,
117 Severity::Medium,
118 "Catastrophic backtracking pattern detected",
119 input,
120 )
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::test_helpers::{assert_clean, assert_detected};
128
129 fn det() -> ReDoSDetector {
130 ReDoSDetector
131 }
132
133 fn assert_hit(input: &str) {
134 assert_detected(&det(), input, AttackCategory::Data, Severity::Medium);
135 }
136
137 #[test]
138 fn name_is_redos() {
139 assert_eq!(det().name(), "redos");
140 }
141
142 #[test]
143 fn detects_nested_quantifiers() {
144 for input in [
145 "(a+)+",
146 "(a*)*",
147 "(.+)+",
148 "([a-z]*)+",
149 "(\\d+)+$",
150 "^([a-zA-Z]+)*$",
151 ] {
152 assert_hit(input);
153 }
154 }
155
156 #[test]
157 fn detects_nested_bounded_repeats() {
158 for input in [
159 "(a+){2,}",
160 "(a{2,})*",
161 "(a{2,}){3,}",
162 "(\\d{1,}){1,}",
163 "^(a{3,})+$",
164 ] {
165 assert_hit(input);
166 }
167 }
168
169 #[test]
170 fn detects_overlapping_alternation() {
171 for input in [
172 "(a|a)*",
173 "(.|x)+",
174 "(\\w|y)*",
175 "(x|)*",
176 "(a|a){1,}",
177 "^(a|b|.)*$",
178 ] {
179 assert_hit(input);
180 }
181 }
182
183 #[test]
184 fn detects_in_longer_expression() {
185 for input in [r"^\s*(a+)+$", r"^(\w+\s?)*$", r"^([a-z]+)*$"] {
186 assert_hit(input);
187 }
188 }
189
190 #[test]
191 fn ignores_safe_regexes() {
192 for input in [
193 r"^\d{4}-\d{2}-\d{2}$",
194 r"^[a-z]+$",
195 r"(GET|POST)",
196 r"(GET|POST)*",
197 r"(foo|bar)*",
198 r"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$",
199 r"\(escaped\)+",
200 ] {
201 assert_clean(&det(), input);
202 }
203 }
204
205 #[test]
206 fn ignores_disjoint_alternation() {
207 for input in [
210 "(a|b)*",
211 "(0|1)+",
212 "(y|n)*",
213 "(x|y|z)+",
214 "^(a|b)+$",
215 "(a|b){2,}",
216 "(?i)(a|b)*",
217 "选项(是|否)*",
218 "步骤(1|2)*3",
219 "价格 (元+)* 说明",
220 "注意(重要+)*提醒",
221 ] {
222 assert_clean(&det(), input);
223 }
224 }
225
226 #[test]
227 fn ignores_benign_inputs() {
228 for input in [
229 "Hello, this is a normal text input. Nothing suspicious here.",
230 "5*(3+2)",
231 "the (very) long text",
232 "function(a, b)",
233 "穿越之霸道总裁爱上我--重生之都市修仙",
234 ] {
235 assert_clean(&det(), input);
236 }
237 }
238
239 #[test]
240 fn still_reports_same_prefix_branches() {
241 for input in ["(a|a)*", "(a|ab)*", "(a|a){1,}"] {
243 assert_hit(input);
244 }
245 assert_clean(&det(), "(a|a){2}");
247 }
248
249 #[test]
258 fn paren_flood_stays_linear() {
259 use std::time::{Duration, Instant};
260 let unclosed = "(".repeat(1_000_000);
262 let t = Instant::now();
263 let hit = repeated_prefix_branch(&unclosed);
264 let elapsed = t.elapsed();
265 assert!(hit.is_none(), "光有括号不是 ReDoS");
266 assert!(
267 elapsed < Duration::from_secs(10),
268 "二次方重扫回来了:{} 个字符耗时 {elapsed:?}(线性实现是微秒级)",
269 unclosed.len()
270 );
271 let closed = format!("{})", "(".repeat(200_000));
273 assert!(repeated_prefix_branch(&closed).is_none());
274 }
275
276 #[test]
278 fn ignores_realistic_source_and_prose() {
279 for input in [
280 "def summarize(items, limit=10):\n names = [i.name for i in items if i.name]\n top = names[:limit]\n return (len(top), top)\n",
282 "if (a or b) and not (c or d):\n print((a, b))\n",
283 "PATTERN = re.compile(r\"^[a-z0-9]+$\") # (safe) no nested quantifier\n",
284 "步骤(1|2)*3 是笔误,正确写法是 (a|b)*,请核对",
286 "价格 (元+)* 说明:括号里的加号是正文,不是正则",
287 "See (a) and (b)* in the appendix; (x|y)* means repeat either.",
288 "The customer wrote: 我的号码是 (010) 1234-5678, 请回电",
289 ] {
290 assert_clean(&det(), input);
291 }
292 }
293
294 #[test]
295 fn edge_cases() {
296 assert_clean(&det(), "");
297 assert_clean(&det(), " ");
298 assert_clean(&det(), "()");
299 assert_clean(&det(), "()*");
300 assert_clean(&det(), "(a)");
301 assert_clean(&det(), "(really)*");
303 assert_clean(&det(), r"^(([a-z])+.)+[A-Z]([a-z])+$");
305 assert_clean(&det(), "^([0-9]|[0-9]){1,}$");
306 }
307}