Skip to main content

waf_detection/
nosql.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::{all_matches, body_str_values, inspectable_header_values, Rule};
9
10// ── rules (Fase 10a) ────────────────────────────────────────────────────────────
11//
12// NoSQL injection (MongoDB-flavoured). Two confidence tiers (decision 3):
13//   - server-side JS execution / shell-method calls = UNEQUIVOCAL → Critical, block-alone;
14//   - boolean/comparison OPERATORS = ambiguous (could appear in benign JSON-ish text) →
15//     Warning, accumulate.
16// FP discipline: the operator rule matches the CLOSED set of Mongo operators bounded by
17// `\b`, so a benign `$`-prefixed JSON field name (`$schema`, `$ref`, `$id`) or a currency
18// `$100` is NOT flagged — see the benign guards in the corpus. CRS-derived; patterns
19// target the normalizer OUTPUT (Fase 2, ASCII, NFKC-stable). The Base64Flat duplicates
20// are 10c-scope (corpus `ExpectedMiss{until_phase:"10c"}`).
21
22pub static NOSQL_RULES: &[Rule] = &[
23    Rule {
24        id: "nosql-where-js",
25        // `$where` runs server-side JavaScript — unequivocal.
26        pattern: r"\$where\b",
27        severity: Severity::Critical,
28        paranoia: 1,
29    },
30    Rule {
31        id: "nosql-shell-method",
32        // Mongo shell collection method call: `db.<coll>.insert(` etc.
33        pattern: r"\bdb\.\w+\.(?:insert|find|update|remove|save|drop|count|aggregate)\s*\(",
34        severity: Severity::Critical,
35        paranoia: 1,
36    },
37    Rule {
38        id: "nosql-js-timing",
39        // Busy-loop timing payload injected into a `$where`-style JS context.
40        pattern: r"do\s*\{[^}]*\}\s*while\s*\(",
41        severity: Severity::Warning,
42        paranoia: 2,
43    },
44    Rule {
45        id: "nosql-operator",
46        // Boolean/comparison operators — ambiguous, accumulate. Closed set + `\b` so
47        // benign `$schema`/`$ref`/`$100` do not match.
48        pattern: r"\$(?:or|and|nor|ne|gt|gte|lt|lte|nin|in|regex|exists|expr|elemMatch)\b",
49        severity: Severity::Warning,
50        paranoia: 2,
51    },
52];
53
54// ── module ──────────────────────────────────────────────────────────────────────
55
56#[derive(Default)]
57pub struct NosqlModule {
58    rule_set: Option<RegexSet>,
59    active_rules: Vec<&'static Rule>,
60}
61
62impl NosqlModule {
63    pub fn new() -> Self {
64        Self::default()
65    }
66}
67
68impl WafModule for NosqlModule {
69    fn id(&self) -> &str {
70        "nosql"
71    }
72
73    fn phase(&self) -> Phase {
74        Phase::Body
75    }
76
77    fn init(&mut self, cfg: &Config) {
78        let pl = cfg.waf.paranoia_level;
79        self.active_rules = NOSQL_RULES.iter().filter(|r| r.paranoia <= pl).collect();
80        self.rule_set = Some(
81            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
82                .expect("NoSQL rule compilation failed — check patterns at startup"),
83        );
84    }
85
86    fn inspect(&self, ctx: &RequestContext) -> Decision {
87        let Some(rule_set) = &self.rule_set else {
88            return Decision::Allow;
89        };
90
91        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
92        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
93        let body_vals = body_str_values(&ctx.normalized.body);
94        let body = body_vals.iter().map(String::as_str);
95        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
96
97        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
98        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
99        // path-placed payload bypasses. The prefilter already scans the path (sound).
100        let path = std::iter::once(ctx.normalized.path.as_str());
101        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
102        let headers = inspectable_header_values(ctx);
103        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
104        if matched.is_empty() {
105            return Decision::Allow;
106        }
107
108        let items: Vec<ScoreItem> = matched
109            .iter()
110            .map(|&idx| {
111                let rule = self.active_rules[idx];
112                warn!(
113                    request_id = %ctx.request_id,
114                    rule_id = %rule.id,
115                    severity = ?rule.severity,
116                    "nosql detection"
117                );
118                ScoreItem {
119                    rule_id: rule.id.to_string(),
120                    severity: rule.severity,
121                }
122            })
123            .collect();
124
125        Decision::Scores(items)
126    }
127}