use std::sync::OnceLock;
use regex::RegexSet;
static NOSQL_PATTERNS: OnceLock<RegexSet> = OnceLock::new();
static NOSQL_DESCRIPTIONS: &[&str] = &[
"NoSQL operator injection ($gt/$ne/$lt/$gte/$lte)",
"NoSQL $where / $regex injection",
"NoSQL $or/$and/$nor array injection",
"MongoDB $lookup / $unionWith aggregation abuse",
];
fn patterns() -> &'static RegexSet {
NOSQL_PATTERNS.get_or_init(|| {
RegexSet::new([
r#"(?i)"\$(gt|gte|lt|lte|ne|eq|in|nin)"\s*:"#,
r#"(?i)"\$(where|regex|expr|text|mod)"\s*:"#,
r#"(?i)"\$(or|and|nor|not)"\s*:\s*\["#,
r#"(?i)"\$(lookup|unionWith|merge|out|graphLookup)"\s*:"#,
])
.expect("NoSQL regex patterns must compile")
})
}
pub fn check_nosql(input: &str) -> Option<String> {
let set = patterns();
let matches: Vec<_> = set.matches(input).into_iter().collect();
if matches.is_empty() {
None
} else {
let idx = matches[0];
Some(
NOSQL_DESCRIPTIONS
.get(idx)
.unwrap_or(&"NoSQL injection")
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_comparison_operators() {
assert!(check_nosql(r#"{"username":"admin","password":{"$ne":""}}"#).is_some());
assert!(check_nosql(r#"{"age":{"$gt":0}}"#).is_some());
}
#[test]
fn detects_where_regex() {
assert!(check_nosql(r#"{"$where":"this.password.match(/admin/)"}"#).is_some());
assert!(check_nosql(r#"{"name":{"$regex":"^admin"}}"#).is_some());
}
#[test]
fn detects_logical_operators() {
assert!(check_nosql(r#"{"$or":[{"user":"admin"},{"user":"root"}]}"#).is_some());
}
#[test]
fn allows_normal_json() {
assert!(check_nosql(r#"{"name":"John","age":30}"#).is_none());
}
#[test]
fn allows_normal_text() {
assert!(check_nosql("Hello world").is_none());
}
}