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: ®ex::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}