use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
const Q: &str = r"(?:[+*?]|\{\d+,\})";
Regex::new(
&[
r"\([!-~]{1,60}[+*?]\)[+*]",
r"|\([!-~]{1,60}[+*?]\)\{\d+,\}",
r"|\([!-~]{1,60}\{\d+,\}\)[+*]",
r"|\([!-~]{1,60}\{\d+,\}\)\{\d+,\}",
r"|\([!-~]{0,60}(?:\|\.|\.\|)[!-~]{0,60}\)",
Q,
r"|\(\\[dwsDWS]\|(?:\\[dwsDWS]|[A-Za-z0-9_])\)",
Q,
r"|\((?:[!-~]{0,60}\||\|[!-~]{0,60})\)",
Q,
]
.concat(),
)
.unwrap()
});
fn repeated_prefix_branch(input: &str) -> Option<(usize, usize)> {
let last_close = input.rfind(')')?;
let mut next_close = input.find(')')?;
for (open, _) in input.match_indices('(') {
if open > last_close {
break;
}
if open > next_close {
next_close = open + input[open..].find(')')?;
}
let close = next_close;
let tail = &input[close + 1..];
let quant_len = match tail.chars().next() {
Some('*' | '+') => 1,
Some('{') => match tail.find('}') {
Some(e) if tail[..e].contains(',') => e + 1,
_ => 0,
},
_ => continue,
};
if quant_len == 0 {
continue;
}
let mut branches = input[open + 1..close].split('|');
let Some(first) = branches.next() else {
continue;
};
let mut fc = first.chars();
let (Some(c), None) = (fc.next(), fc.next()) else {
continue;
};
if c.is_ascii_alphanumeric() && branches.any(|b| b.starts_with(c)) {
return Some((open, quant_len));
}
}
None
}
pub struct ReDoSDetector;
impl Detector for ReDoSDetector {
fn name(&self) -> &'static str {
"redos"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
if let Some((open, quant_len)) = repeated_prefix_branch(input) {
let end = input[open..]
.find(')')
.map_or(input.len(), |e| open + e + 1 + quant_len);
return Some(DetectionResult {
attack_type: self.name().to_string(),
category: AttackCategory::Data,
severity: Severity::Medium,
matched_pattern: input[open..end].to_string(),
offset: open,
message: "Catastrophic backtracking pattern detected".into(),
});
}
regex_detect(
std::slice::from_ref(&*PATTERNS),
self.name(),
AttackCategory::Data,
Severity::Medium,
"Catastrophic backtracking pattern detected",
input,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{assert_clean, assert_detected};
fn det() -> ReDoSDetector {
ReDoSDetector
}
fn assert_hit(input: &str) {
assert_detected(&det(), input, AttackCategory::Data, Severity::Medium);
}
#[test]
fn name_is_redos() {
assert_eq!(det().name(), "redos");
}
#[test]
fn detects_nested_quantifiers() {
for input in [
"(a+)+",
"(a*)*",
"(.+)+",
"([a-z]*)+",
"(\\d+)+$",
"^([a-zA-Z]+)*$",
] {
assert_hit(input);
}
}
#[test]
fn detects_nested_bounded_repeats() {
for input in [
"(a+){2,}",
"(a{2,})*",
"(a{2,}){3,}",
"(\\d{1,}){1,}",
"^(a{3,})+$",
] {
assert_hit(input);
}
}
#[test]
fn detects_overlapping_alternation() {
for input in [
"(a|a)*",
"(.|x)+",
"(\\w|y)*",
"(x|)*",
"(a|a){1,}",
"^(a|b|.)*$",
] {
assert_hit(input);
}
}
#[test]
fn detects_in_longer_expression() {
for input in [r"^\s*(a+)+$", r"^(\w+\s?)*$", r"^([a-z]+)*$"] {
assert_hit(input);
}
}
#[test]
fn ignores_safe_regexes() {
for input in [
r"^\d{4}-\d{2}-\d{2}$",
r"^[a-z]+$",
r"(GET|POST)",
r"(GET|POST)*",
r"(foo|bar)*",
r"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$",
r"\(escaped\)+",
] {
assert_clean(&det(), input);
}
}
#[test]
fn ignores_disjoint_alternation() {
for input in [
"(a|b)*",
"(0|1)+",
"(y|n)*",
"(x|y|z)+",
"^(a|b)+$",
"(a|b){2,}",
"(?i)(a|b)*",
"选项(是|否)*",
"步骤(1|2)*3",
"价格 (元+)* 说明",
"注意(重要+)*提醒",
] {
assert_clean(&det(), input);
}
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"5*(3+2)",
"the (very) long text",
"function(a, b)",
"穿越之霸道总裁爱上我--重生之都市修仙",
] {
assert_clean(&det(), input);
}
}
#[test]
fn still_reports_same_prefix_branches() {
for input in ["(a|a)*", "(a|ab)*", "(a|a){1,}"] {
assert_hit(input);
}
assert_clean(&det(), "(a|a){2}");
}
#[test]
fn paren_flood_stays_linear() {
use std::time::{Duration, Instant};
let unclosed = "(".repeat(1_000_000);
let t = Instant::now();
let hit = repeated_prefix_branch(&unclosed);
let elapsed = t.elapsed();
assert!(hit.is_none(), "光有括号不是 ReDoS");
assert!(
elapsed < Duration::from_secs(10),
"二次方重扫回来了:{} 个字符耗时 {elapsed:?}(线性实现是微秒级)",
unclosed.len()
);
let closed = format!("{})", "(".repeat(200_000));
assert!(repeated_prefix_branch(&closed).is_none());
}
#[test]
fn ignores_realistic_source_and_prose() {
for input in [
"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",
"if (a or b) and not (c or d):\n print((a, b))\n",
"PATTERN = re.compile(r\"^[a-z0-9]+$\") # (safe) no nested quantifier\n",
"步骤(1|2)*3 是笔误,正确写法是 (a|b)*,请核对",
"价格 (元+)* 说明:括号里的加号是正文,不是正则",
"See (a) and (b)* in the appendix; (x|y)* means repeat either.",
"The customer wrote: 我的号码是 (010) 1234-5678, 请回电",
] {
assert_clean(&det(), input);
}
}
#[test]
fn edge_cases() {
assert_clean(&det(), "");
assert_clean(&det(), " ");
assert_clean(&det(), "()");
assert_clean(&det(), "()*");
assert_clean(&det(), "(a)");
assert_clean(&det(), "(really)*");
assert_clean(&det(), r"^(([a-z])+.)+[A-Z]([a-z])+$");
assert_clean(&det(), "^([0-9]|[0-9]){1,}$");
}
}