use regex::{Regex, RegexSet};
use tracing::warn;
use waf_core::{Config, Decision, ParsedBody, Phase, RequestContext, ScoreItem, Severity, WafModule};
use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
pub(crate) const NOSQL_OPERATOR_KEY_PATTERN: &str =
r"\$(?:or|and|nor|not|ne|eq|gt|gte|lt|lte|nin|in|regex|exists|expr|elemMatch|all|size|type|mod|where|jsonSchema)\b";
pub(crate) const PROTO_POLLUTION_KEY_PATTERN: &str = r"\b__proto__\b|\bconstructor\.prototype\b";
pub(crate) fn body_key_strings(body: &ParsedBody) -> Vec<&str> {
match body {
ParsedBody::JsonFlattened(p) | ParsedBody::FormUrlEncoded(p) => {
p.iter().map(|(k, _)| k.as_str()).collect()
}
ParsedBody::Multipart(fields) => fields.iter().map(|f| f.name.as_str()).collect(),
_ => Vec::new(),
}
}
pub static NOSQL_RULES: &[Rule] = &[
Rule {
id: "nosql-where-js",
pattern: r"\$where\b",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "nosql-shell-method",
pattern: r"\bdb\.\w+\.(?:insert|find|update|remove|save|drop|count|aggregate)\s*\(",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "nosql-js-timing",
pattern: r"do\s*\{[^}]*\}\s*while\s*\(",
severity: Severity::Warning,
paranoia: 2,
},
Rule {
id: "nosql-operator",
pattern: r"\$(?:or|and|nor|ne|gt|gte|lt|lte|nin|in|regex|exists|expr|elemMatch)\b",
severity: Severity::Warning,
paranoia: 2,
},
];
#[derive(Default)]
pub struct NosqlModule {
rule_set: Option<RegexSet>,
active_rules: Vec<&'static Rule>,
operator_key: Option<Regex>,
proto_pollution: Option<Regex>,
}
impl NosqlModule {
pub fn new() -> Self {
Self::default()
}
}
impl WafModule for NosqlModule {
fn id(&self) -> &str {
"nosql"
}
fn phase(&self) -> Phase {
Phase::Body
}
fn init(&mut self, cfg: &Config) {
let pl = cfg.waf.paranoia_level;
self.active_rules = NOSQL_RULES.iter().filter(|r| r.paranoia <= pl).collect();
self.rule_set = Some(
RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
.expect("NoSQL rule compilation failed — check patterns at startup"),
);
self.operator_key =
Some(Regex::new(NOSQL_OPERATOR_KEY_PATTERN).expect("NoSQL key pattern compilation"));
self.proto_pollution = Some(
Regex::new(PROTO_POLLUTION_KEY_PATTERN).expect("proto-pollution key pattern compilation"),
);
}
fn inspect(&self, ctx: &RequestContext) -> Decision {
let Some(rule_set) = &self.rule_set else {
return Decision::Allow;
};
let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
let body_vals = body_str_values(&ctx.normalized.body);
let body = body_vals.iter().map(String::as_str);
let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
let path = std::iter::once(ctx.normalized.path.as_str());
let headers = inspectable_header_values(ctx);
let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
let mut items: Vec<ScoreItem> = matched
.iter()
.map(|&idx| {
let rule = self.active_rules[idx];
warn!(
request_id = %ctx.request_id,
rule_id = %rule.id,
severity = ?rule.severity,
"nosql detection"
);
ScoreItem {
rule_id: rule.id.to_string(),
severity: rule.severity,
}
})
.collect();
let keys: Vec<&str> = ctx
.normalized
.query_params
.iter()
.map(|(k, _)| k.as_str())
.chain(body_key_strings(&ctx.normalized.body))
.collect();
if let Some(re) = &self.operator_key {
if keys.iter().any(|k| re.is_match(k))
&& !items.iter().any(|i| i.rule_id == "nosql-operator-key")
{
warn!(
request_id = %ctx.request_id,
rule_id = "nosql-operator-key",
severity = ?Severity::Critical,
"nosql detection"
);
items.push(ScoreItem {
rule_id: "nosql-operator-key".to_string(),
severity: Severity::Critical,
});
}
}
if let Some(re) = &self.proto_pollution {
if keys.iter().any(|k| re.is_match(k))
&& !items.iter().any(|i| i.rule_id == "proto-pollution-key")
{
warn!(
request_id = %ctx.request_id,
rule_id = "proto-pollution-key",
severity = ?Severity::Critical,
"nosql detection"
);
items.push(ScoreItem {
rule_id: "proto-pollution-key".to_string(),
severity: Severity::Critical,
});
}
}
if items.is_empty() {
Decision::Allow
} else {
Decision::Scores(items)
}
}
}