Skip to main content

waf_detection/
ldap.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// LDAP injection = LDAP *search-filter* syntax injected into a user-controlled value.
13// Scope (§8): boolean filter combinators and extensible-match — distinct from SQL/NoSQL.
14// CRS-derived. The discriminator vs a LEGITIMATE single filter `(cn=John Doe)` is the
15// boolean COMBINATOR `(&(` / `(|(`: a normal filter has none, an injected one builds a
16// compound filter (auth-bypass, enumeration). A lone `*` or `=` is NOT enough to flag —
17// see the benign guards in the corpus. Unequivocal LDAP filter syntax → Critical,
18// block-alone (decision 3); pattern targets the normalizer OUTPUT (Fase 2, ASCII, NFKC-
19// stable).
20//
21// Fase 10a covers the URL/Plain-encoded form; the Base64Flat duplicates are 10c-scope
22// (need base64-decode in §6, invariant here).
23
24pub static LDAP_RULES: &[Rule] = &[
25    Rule {
26        id: "ldap-logical-filter",
27        // Boolean filter combinator `(&(` or `(|(`. Matches the gotestwaf
28        // `(&(uid=admin)(!(&(1=0)(userPassword=q))))` and `*(|(objectclass=*))`;
29        // a single `(cn=John Doe)` has no combinator and is not flagged.
30        pattern: r"\((?:&|\|)\(",
31        severity: Severity::Critical,
32        paranoia: 1,
33    },
34    Rule {
35        id: "ldap-extensible-match",
36        // Extensible-match with an OID or `dn` matching rule: `:<oid>:=` / `:dn:=`.
37        // Matches `userPassword:2.5.13.18:=123`; a bare OID value `2.5.4.3` has no
38        // `:…:=` and is not flagged.
39        pattern: r"(?i):(?:dn|\d+(?:\.\d+)+):=",
40        severity: Severity::Critical,
41        paranoia: 1,
42    },
43];
44
45// ── module ──────────────────────────────────────────────────────────────────────
46
47#[derive(Default)]
48pub struct LdapModule {
49    rule_set: Option<RegexSet>,
50    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
51    active_rules: Vec<&'static Rule>,
52}
53
54impl LdapModule {
55    pub fn new() -> Self {
56        Self::default()
57    }
58}
59
60impl WafModule for LdapModule {
61    fn id(&self) -> &str {
62        "ldap"
63    }
64
65    fn phase(&self) -> Phase {
66        Phase::Body
67    }
68
69    fn init(&mut self, cfg: &Config) {
70        let pl = cfg.waf.paranoia_level;
71        self.active_rules = LDAP_RULES.iter().filter(|r| r.paranoia <= pl).collect();
72        self.rule_set = Some(
73            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
74                .expect("LDAP rule compilation failed — check patterns at startup"),
75        );
76    }
77
78    fn inspect(&self, ctx: &RequestContext) -> Decision {
79        let Some(rule_set) = &self.rule_set else {
80            return Decision::Allow;
81        };
82
83        let query = ctx.normalized.query_params.iter().map(|(_, v)| v.as_str());
84        let cookies = ctx.normalized.cookies.iter().map(|(_, v)| v.as_str());
85        let body_vals = body_str_values(&ctx.normalized.body);
86        let body = body_vals.iter().map(String::as_str);
87        let derived = ctx.normalized.derived_decoded.iter().map(String::as_str);
88
89        // URLPath coverage (10c REOPEN, pcap-confirmed): gotestwaf places payloads in the
90        // URL PATH; this module must scan the resolved path too (mirrors rce/xss), else a
91        // path-placed payload bypasses. The prefilter already scans the path (sound).
92        let path = std::iter::once(ctx.normalized.path.as_str());
93        // P1-B: also scan the allowlisted request headers (Referer / X-Forwarded-* / custom x-*).
94        let headers = inspectable_header_values(ctx);
95        let matched = all_matches(rule_set, path.chain(query).chain(cookies).chain(body).chain(derived).chain(headers));
96        if matched.is_empty() {
97            return Decision::Allow;
98        }
99
100        let items: Vec<ScoreItem> = matched
101            .iter()
102            .map(|&idx| {
103                let rule = self.active_rules[idx];
104                warn!(
105                    request_id = %ctx.request_id,
106                    rule_id = %rule.id,
107                    severity = ?rule.severity,
108                    "ldap detection"
109                );
110                ScoreItem {
111                    rule_id: rule.id.to_string(),
112                    severity: rule.severity,
113                }
114            })
115            .collect();
116
117        Decision::Scores(items)
118    }
119}