1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
11 const Q: &str = r"(?:[+*?]|\{\d+,\})";
13 vec![
16 Regex::new(r"\([!-~]{1,60}[+*?]\)[+*]").unwrap(),
18 Regex::new(r"\([!-~]{1,60}[+*?]\)\{\d+,\}").unwrap(),
20 Regex::new(r"\([!-~]{1,60}\{\d+,\}\)[+*]").unwrap(),
22 Regex::new(r"\([!-~]{1,60}\{\d+,\}\)\{\d+,\}").unwrap(),
23 Regex::new(&[r"\([!-~]{0,60}(?:\|\.|\.\|)[!-~]{0,60}\)", Q].concat()).unwrap(),
26 Regex::new(&[r"\(\\[dwsDWS]\|(?:\\[dwsDWS]|[A-Za-z0-9_])\)", Q].concat()).unwrap(),
29 Regex::new(&[r"\((?:[!-~]{0,60}\||\|[!-~]{0,60})\)", Q].concat()).unwrap(),
31 ]
32});
33
34fn repeated_prefix_branch(input: &str) -> Option<(usize, usize)> {
38 for (open, _) in input.match_indices('(') {
39 let Some(rel_close) = input[open..].find(')') else {
40 continue;
41 };
42 let close = open + rel_close;
43 let tail = &input[close + 1..];
45 let quant_len = match tail.chars().next() {
46 Some('*' | '+') => 1,
47 Some('{') => match tail.find('}') {
48 Some(e) if tail[..e].contains(',') => e + 1,
49 _ => 0,
50 },
51 _ => continue,
52 };
53 if quant_len == 0 {
54 continue;
55 }
56 let mut branches = input[open + 1..close].split('|');
57 let Some(first) = branches.next() else {
59 continue;
60 };
61 let mut fc = first.chars();
62 let (Some(c), None) = (fc.next(), fc.next()) else {
63 continue;
64 };
65 if c.is_ascii_alphanumeric() && branches.any(|b| b.starts_with(c)) {
66 return Some((open, quant_len));
67 }
68 }
69 None
70}
71
72pub struct ReDoSDetector;
73
74impl Detector for ReDoSDetector {
75 fn name(&self) -> &'static str {
76 "redos"
77 }
78
79 fn detect(&self, input: &str) -> Option<DetectionResult> {
80 if let Some((open, quant_len)) = repeated_prefix_branch(input) {
81 let end = input[open..]
82 .find(')')
83 .map_or(input.len(), |e| open + e + 1 + quant_len);
84 return Some(DetectionResult {
85 attack_type: self.name().to_string(),
86 category: AttackCategory::Data,
87 severity: Severity::Medium,
88 matched_pattern: input[open..end].to_string(),
89 offset: open,
90 message: "Catastrophic backtracking pattern detected".into(),
91 });
92 }
93 regex_detect(
94 &PATTERNS,
95 self.name(),
96 AttackCategory::Data,
97 Severity::Medium,
98 "Catastrophic backtracking pattern detected",
99 input,
100 )
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::test_helpers::{assert_clean, assert_detected};
108
109 fn det() -> ReDoSDetector {
110 ReDoSDetector
111 }
112
113 fn assert_hit(input: &str) {
114 assert_detected(&det(), input, AttackCategory::Data, Severity::Medium);
115 }
116
117 #[test]
118 fn name_is_redos() {
119 assert_eq!(det().name(), "redos");
120 }
121
122 #[test]
123 fn detects_nested_quantifiers() {
124 for input in [
125 "(a+)+",
126 "(a*)*",
127 "(.+)+",
128 "([a-z]*)+",
129 "(\\d+)+$",
130 "^([a-zA-Z]+)*$",
131 ] {
132 assert_hit(input);
133 }
134 }
135
136 #[test]
137 fn detects_nested_bounded_repeats() {
138 for input in [
139 "(a+){2,}",
140 "(a{2,})*",
141 "(a{2,}){3,}",
142 "(\\d{1,}){1,}",
143 "^(a{3,})+$",
144 ] {
145 assert_hit(input);
146 }
147 }
148
149 #[test]
150 fn detects_overlapping_alternation() {
151 for input in [
152 "(a|a)*",
153 "(.|x)+",
154 "(\\w|y)*",
155 "(x|)*",
156 "(a|a){1,}",
157 "^(a|b|.)*$",
158 ] {
159 assert_hit(input);
160 }
161 }
162
163 #[test]
164 fn detects_in_longer_expression() {
165 for input in [r"^\s*(a+)+$", r"^(\w+\s?)*$", r"^([a-z]+)*$"] {
166 assert_hit(input);
167 }
168 }
169
170 #[test]
171 fn ignores_safe_regexes() {
172 for input in [
173 r"^\d{4}-\d{2}-\d{2}$",
174 r"^[a-z]+$",
175 r"(GET|POST)",
176 r"(GET|POST)*",
177 r"(foo|bar)*",
178 r"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$",
179 r"\(escaped\)+",
180 ] {
181 assert_clean(&det(), input);
182 }
183 }
184
185 #[test]
186 fn ignores_disjoint_alternation() {
187 for input in [
190 "(a|b)*",
191 "(0|1)+",
192 "(y|n)*",
193 "(x|y|z)+",
194 "^(a|b)+$",
195 "(a|b){2,}",
196 "(?i)(a|b)*",
197 "选项(是|否)*",
198 "步骤(1|2)*3",
199 "价格 (元+)* 说明",
200 "注意(重要+)*提醒",
201 ] {
202 assert_clean(&det(), input);
203 }
204 }
205
206 #[test]
207 fn ignores_benign_inputs() {
208 for input in [
209 "Hello, this is a normal text input. Nothing suspicious here.",
210 "5*(3+2)",
211 "the (very) long text",
212 "function(a, b)",
213 "穿越之霸道总裁爱上我--重生之都市修仙",
214 ] {
215 assert_clean(&det(), input);
216 }
217 }
218
219 #[test]
220 fn still_reports_same_prefix_branches() {
221 for input in ["(a|a)*", "(a|ab)*", "(a|a){1,}"] {
223 assert_hit(input);
224 }
225 assert_clean(&det(), "(a|a){2}");
227 }
228
229 #[test]
230 fn edge_cases() {
231 assert_clean(&det(), "");
232 assert_clean(&det(), " ");
233 assert_clean(&det(), "()");
234 assert_clean(&det(), "()*");
235 assert_clean(&det(), "(a)");
236 assert_clean(&det(), "(really)*");
238 assert_clean(&det(), r"^(([a-z])+.)+[A-Z]([a-z])+$");
240 assert_clean(&det(), "^([0-9]|[0-9]){1,}$");
241 }
242}