Skip to main content

waf_detection/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod crs;
5pub mod graphql;
6pub mod grpc;
7pub mod header_injection;
8pub mod ldap;
9pub mod lfi_rfi;
10pub mod mail;
11pub mod nosql;
12pub mod path_traversal;
13pub mod rate_limit;
14pub mod rce;
15pub mod request_smuggling;
16pub mod scanner;
17pub mod sqli;
18pub mod ssi;
19pub mod ssrf;
20pub mod ssti;
21pub mod xss;
22pub mod xxe;
23
24use regex::RegexSet;
25use waf_core::{ParsedBody, RequestContext, Severity};
26use waf_normalizer::url::canonicalize_multipart_field;
27
28/// Highest paranoia level any shipped rule currently declares. The config
29/// contract allows up to `waf_core::MAX_PARANOIA_LEVEL` (4), but no rule uses 4
30/// yet — so a higher `paranoia_level` activates no additional rules. The proxy
31/// logs this at startup so PL4 is "empty but legal", never silently == PL3.
32/// Bump this when the first higher-paranoia rule is added.
33pub const HIGHEST_RULE_PARANOIA: u8 = 3;
34
35// ── Rule ──────────────────────────────────────────────────────────────────────
36
37/// A single detection rule: an id (used as `rule_id` in the emitted decision),
38/// a regex pattern compiled into a `RegexSet` at module init time, a severity
39/// class (mapped to points by the pipeline), and the minimum paranoia level at
40/// which the rule becomes active.
41pub struct Rule {
42    pub id: &'static str,
43    pub pattern: &'static str,
44    pub severity: Severity,
45    /// Minimum configured paranoia level (1..=4) for this rule to be compiled.
46    pub paranoia: u8,
47}
48
49// ── shared helpers ────────────────────────────────────────────────────────────
50
51/// Return the indices of every pattern in `rule_set` that matches at least one
52/// of the given values, deduplicated and sorted. A rule that matches in several
53/// values (query + body + cookie) is counted once, mirroring CRS semantics.
54pub(crate) fn all_matches(
55    rule_set: &regex::RegexSet,
56    values: impl Iterator<Item = impl AsRef<str>>,
57) -> Vec<usize> {
58    let mut matched = vec![false; rule_set.len()];
59    for v in values {
60        for idx in rule_set.matches(v.as_ref()).into_iter() {
61            matched[idx] = true;
62        }
63    }
64    matched
65        .iter()
66        .enumerate()
67        .filter_map(|(i, &hit)| if hit { Some(i) } else { None })
68        .collect()
69}
70
71// ── content prefilter (Fase 7 / Pilastro 3: sound fast-path skip) ─────────────
72
73/// A list of `(rule_id, pattern)` for one scope bucket of the content prefilter.
74pub type RuleList = Vec<(&'static str, &'static str)>;
75
76/// `(rule_id, pattern)` for every CONTENT-inspection rule active at `paranoia`,
77/// split into two **scope buckets** (request_smuggling is excluded — it is
78/// structural framing validation, not regex content inspection, and always runs in
79/// the connection phase). Single source for the prefilter, so it cannot drift from
80/// the per-module rules.
81///
82/// - `main`: every rule whose pattern is safe to scan over a **superset** of the
83///   fields it inspects ({path, query, cookies, headers, body}) — the 6 content
84///   modules plus the non-host header-injection rules. Over-scanning is sound
85///   (it can only over-flag → run the full path), and these patterns are specific
86///   enough not to match benign paths/headers.
87/// - `host`: the host-only header-injection rule(s) (`Scope::HostHeaders`), whose
88///   broad `[/@]` pattern MUST be scanned **only** against host header values —
89///   scanning it over the path (always `/`) is what made a global union useless.
90pub fn content_rules_split(paranoia: u8) -> (RuleList, RuleList) {
91    let mut main: RuleList = Vec::new();
92    let mut host: RuleList = Vec::new();
93    for table in [
94        sqli::SQLI_RULES,
95        xss::XSS_RULES,
96        path_traversal::PATH_TRAVERSAL_RULES,
97        rce::RCE_RULES,
98        lfi_rfi::LFI_RFI_RULES,
99        ssrf::SSRF_RULES,
100        ldap::LDAP_RULES,
101        nosql::NOSQL_RULES,
102        mail::MAIL_RULES,
103        ssti::SSTI_RULES,
104        scanner::SCANNER_RULES,
105        ssi::SSI_RULES,
106        xxe::XXE_RULES,
107    ] {
108        for r in table.iter().filter(|r| r.paranoia <= paranoia) {
109            main.push((r.id, r.pattern));
110        }
111    }
112    for (id, pattern, par, host_only) in header_injection::rule_meta() {
113        if par <= paranoia {
114            if host_only {
115                host.push((id, pattern));
116            } else {
117                main.push((id, pattern));
118            }
119        }
120    }
121    (main, host)
122}
123
124/// A sound, **scope-aware** skip-prefilter: per-scope unions (`RegexSet`) of the
125/// active content patterns, evaluated over the **canonical** surface (§6).
126///
127/// Soundness is by construction *per scope*: each rule's pattern is scanned over a
128/// **superset** of the fields it actually inspects, so a clean result on every
129/// scanned surface proves no content rule matches → the full inspection would
130/// return Allow → the caller may safely skip it. The check can only err toward
131/// "candidate" (run the full path), never toward a wrong skip.
132///
133/// NB: a character-class pre-check (looking for `<`/`'`/`%`/…) would be UNSOUND —
134/// keyword rules match plain alphanumerics (`union select`, `sleep(`, `/etc/passwd`)
135/// and normalization turns `%3C`/fullwidth into `<` — so this MUST stay a regex
136/// union over the same patterns and the same canonical surface as the modules.
137pub struct ContentPrefilter {
138    /// Scanned over path + query + cookies + headers + body.
139    main: RegexSet,
140    /// Scanned over host header values only.
141    host: RegexSet,
142    main_ids: Vec<&'static str>,
143    host_ids: Vec<&'static str>,
144}
145
146impl ContentPrefilter {
147    /// Build the scope-aware prefilter for all content rules active at `paranoia`.
148    pub fn new(paranoia: u8) -> Self {
149        let (main_rules, host_rules) = content_rules_split(paranoia);
150        let main = RegexSet::new(main_rules.iter().map(|(_, p)| *p))
151            .expect("content prefilter MAIN union compilation failed");
152        let host = RegexSet::new(host_rules.iter().map(|(_, p)| *p))
153            .expect("content prefilter HOST union compilation failed");
154        Self {
155            main,
156            host,
157            main_ids: main_rules.iter().map(|(id, _)| *id).collect(),
158            host_ids: host_rules.iter().map(|(id, _)| *id).collect(),
159        }
160    }
161
162    /// All rule_ids covered (main + host) — total-completeness assertions.
163    pub fn rule_ids(&self) -> Vec<&'static str> {
164        self.main_ids.iter().chain(&self.host_ids).copied().collect()
165    }
166
167    /// rule_ids in the host-only bucket — scope-correspondence assertions (must be
168    /// exactly the `Scope::HostHeaders` rules, never a content rule).
169    pub fn host_rule_ids(&self) -> &[&'static str] {
170        &self.host_ids
171    }
172
173    /// `true` = at least one pattern *might* match the canonical surface → the full
174    /// inspection MUST run. `false` = **provably** no content rule can match → the
175    /// caller may skip inspection and treat the request as Allow.
176    pub fn is_candidate(&self, ctx: &RequestContext) -> bool {
177        let n = &ctx.normalized;
178        // MAIN bucket over the superset surface.
179        if self.main.is_match(&n.path) {
180            return true;
181        }
182        for (_, v) in n.query_params.iter().chain(&n.cookies).chain(&n.headers) {
183            if self.main.is_match(v) {
184                return true;
185            }
186        }
187        if body_str_values(&n.body).iter().any(|v| self.main.is_match(v)) {
188            return true;
189        }
190        // Base64-DERIVED surface (10c): the modules inspect `derived_decoded`, so the
191        // prefilter MUST scan it too — else a Base64Flat payload (raw value matches no
192        // pattern) would be wrongly skipped by the fast-path while full inspection
193        // fires on the decode. Soundness = scan the same surface the modules read.
194        if n.derived_decoded.iter().any(|v| self.main.is_match(v)) {
195            return true;
196        }
197        // HOST bucket over host header values only.
198        n.headers
199            .iter()
200            .filter(|(name, _)| name == "host" || name == "x-forwarded-host")
201            .any(|(_, v)| self.host.is_match(v))
202    }
203}
204
205/// Header VALUES that content modules inspect — the closed allowlist (P1-B):
206/// `Referer`, `X-Forwarded-{For,Host,Proto}` and custom `x-*`, minus the deny-list
207/// (auth/cookie/UA/negotiation/content-*/validators/hop-by-hop/`*-token`). gotestwaf
208/// injects payloads into `X-<random>` headers; this is the surface that reaches them.
209/// The prefilter already over-scans ALL headers (sound); the modules read this subset.
210pub(crate) fn inspectable_header_values(ctx: &RequestContext) -> impl Iterator<Item = &str> {
211    ctx.normalized
212        .headers
213        .iter()
214        .filter(|(name, _)| waf_normalizer::header_content_inspectable(name))
215        .map(|(_, v)| v.as_str())
216}
217
218/// Collect all inspectable string values from a parsed body.
219/// Binary multipart parts that are not valid UTF-8 are silently skipped.
220pub(crate) fn body_str_values(body: &ParsedBody) -> Vec<String> {
221    match body {
222        ParsedBody::FormUrlEncoded(params) => {
223            params.iter().map(|(_, v)| v.clone()).collect()
224        }
225        ParsedBody::JsonFlattened(pairs) => {
226            pairs.iter().map(|(_, v)| v.clone()).collect()
227        }
228        ParsedBody::Multipart(fields) => {
229            // Field-coverage (10b-cont fix): inspect EVERY part field — the form
230            // `name`, the `filename`, AND the value — because gotestwaf's
231            // `community-lfi-multipart` smuggles the traversal in the part `name`
232            // (no filename) or in the value, often double-/overlong-encoded. Each is
233            // run through `canonicalize_multipart_field` (recursive decode + overlong
234            // UTF-8 collapse + NFKC) so `%25C0%25AE…` / `..%2f` resolve to `../`
235            // BEFORE the rules see them. Binary values that are not valid UTF-8
236            // contribute name+filename only.
237            let mut out = Vec::with_capacity(fields.len() * 2);
238            for f in fields {
239                out.push(canonicalize_multipart_field(&f.name));
240                if let Some(filename) = &f.filename {
241                    out.push(canonicalize_multipart_field(filename));
242                }
243                if let Ok(s) = std::str::from_utf8(&f.data) {
244                    out.push(canonicalize_multipart_field(s));
245                }
246            }
247            out
248        }
249        ParsedBody::Raw(bytes) => {
250            // A Raw body is scanned as text ONLY when it IS text. A NUL byte marks a BINARY
251            // body — e.g. a gRPC framed protobuf message (the flag/length header bytes are
252            // NUL) — whose real content is extracted structurally into the derived channel,
253            // not scanned as a raw string. Scanning the framing bytes would false-positive
254            // (the frame header trips `pt-null-byte`; the `0x0a` field tag trips
255            // `hdr-crlf-in-body`). Non-gRPC binary bodies are likewise covered by the
256            // derived channel (`canonicalize_value` + `derive_variants`), so this loses no
257            // coverage.
258            match std::str::from_utf8(bytes) {
259                Ok(s) if !bytes.contains(&0) => vec![s.to_owned()],
260                _ => vec![],
261            }
262        }
263        ParsedBody::None => vec![],
264    }
265}