use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
const Q: &str = r"(?:[+*?]|\{\d+,\})";
vec![
Regex::new(r"\([!-~]{1,60}[+*?]\)[+*]").unwrap(),
Regex::new(r"\([!-~]{1,60}[+*?]\)\{\d+,\}").unwrap(),
Regex::new(r"\([!-~]{1,60}\{\d+,\}\)[+*]").unwrap(),
Regex::new(r"\([!-~]{1,60}\{\d+,\}\)\{\d+,\}").unwrap(),
Regex::new(&[r"\([!-~]{0,60}(?:\|\.|\.\|)[!-~]{0,60}\)", Q].concat()).unwrap(),
Regex::new(&[r"\(\\[dwsDWS]\|(?:\\[dwsDWS]|[A-Za-z0-9_])\)", Q].concat()).unwrap(),
Regex::new(&[r"\((?:[!-~]{0,60}\||\|[!-~]{0,60})\)", Q].concat()).unwrap(),
]
});
fn repeated_prefix_branch(input: &str) -> Option<(usize, usize)> {
for (open, _) in input.match_indices('(') {
let Some(rel_close) = input[open..].find(')') else {
continue;
};
let close = open + rel_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(
&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 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,}$");
}
}