Skip to main content

rust_web_server/waf/
mod.rs

1//! Web application firewall (18.22.0, #77): rws's own rules, no rule
2//! language to learn and no external engine.
3//!
4//! Every request's path, query arguments, form / JSON / multipart field
5//! values (the first [`WafConfig::inspect_body_bytes`]), cookies and a few
6//! headers are decoded ([`decode::normalize`]) and checked against the
7//! built-in detections ([`detect::RULES`]: SQL injection, XSS, path
8//! traversal and sensitive files, file-inclusion wrappers, command
9//! injection, cloud-metadata SSRF, Log4Shell, scanners, argument limits).
10//! Each match adds its score; at [`WafConfig::threshold`] (5: one critical
11//! match) the request is refused with `403` problem+json naming the rules —
12//! or, in `detect` mode, only logged. `paranoia` (1–3) turns on stricter,
13//! noisier rules.
14//!
15//! False positives are excluded by rule id and path prefix
16//! (`exclude = ["942100@/api/search"]`), and custom rules match a field
17//! with `contains` / `prefix` / `equals` / `cidr` (linear-time matching,
18//! no regular expressions) to deny, allow, log or score.
19//!
20//! Config: `[middleware.waf]` (every route) or `[route.middleware.waf]`;
21//! code: [`WafLayer`].
22
23pub mod decode;
24pub mod detect;
25
26use std::collections::BTreeMap;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::{Mutex, OnceLock};
29
30use crate::application::Application;
31use crate::middleware::Middleware;
32use crate::request::Request;
33use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
34use crate::server::ConnectionInfo;
35
36/// Block, only log, or do nothing.
37#[derive(Debug, Clone, Copy, PartialEq, Default)]
38pub enum Mode {
39    #[default]
40    Block,
41    Detect,
42    Off,
43}
44
45impl Mode {
46    pub fn parse(s: &str) -> Option<Mode> {
47        match s.trim().to_ascii_lowercase().as_str() {
48            "block" | "" => Some(Mode::Block),
49            "detect" | "log" => Some(Mode::Detect),
50            "off" => Some(Mode::Off),
51            _ => None,
52        }
53    }
54}
55
56/// Which part of the request a custom rule looks at.
57#[derive(Debug, Clone, PartialEq)]
58pub enum Field {
59    Path,
60    /// The raw query string.
61    Query,
62    /// One argument (query or form) by name.
63    Arg(String),
64    /// Every argument value.
65    Args,
66    Header(String),
67    Method,
68    /// The client address (`cidr` matching).
69    Ip,
70    /// The body as text (the inspected part).
71    Body,
72}
73
74impl Field {
75    pub fn parse(s: &str) -> Option<Field> {
76        let s = s.trim();
77        let lower = s.to_ascii_lowercase();
78        Some(match lower.as_str() {
79            "path" => Field::Path,
80            "query" => Field::Query,
81            "args" => Field::Args,
82            "method" => Field::Method,
83            "ip" => Field::Ip,
84            "body" => Field::Body,
85            _ if lower.starts_with("arg:") => Field::Arg(s[4..].to_string()),
86            _ if lower.starts_with("header:") => Field::Header(s[7..].to_ascii_lowercase()),
87            _ => return None,
88        })
89    }
90}
91
92/// What a matching custom rule does.
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub enum Action {
95    /// Refuse the request (whatever the mode).
96    Deny,
97    /// Skip the WAF for the request (a false-positive escape hatch).
98    Allow,
99    /// Log only.
100    Log,
101    /// Add `score` to the anomaly score.
102    Score(u32),
103}
104
105/// A custom rule.
106#[derive(Debug, Clone, PartialEq)]
107pub struct CustomRule {
108    pub id: u32,
109    pub field: Field,
110    /// Case-insensitive substring / prefix / whole value; or for `ip`, CIDR
111    /// ranges. Any one matching is a match.
112    pub contains: Vec<String>,
113    pub prefix: Vec<String>,
114    pub equals: Vec<String>,
115    pub cidr: Vec<String>,
116    pub action: Action,
117}
118
119/// An exclusion: rule ids (`942100`, `942*`, `*`) off for a path prefix.
120#[derive(Debug, Clone, PartialEq)]
121pub struct Exclusion {
122    pub rule: String,
123    pub path_prefix: String,
124}
125
126impl Exclusion {
127    /// `942100`, `942*@/api/search`, `*@/webhooks/`.
128    pub fn parse(s: &str) -> Exclusion {
129        let (rule, path) = s.trim().split_once('@').unwrap_or((s.trim(), ""));
130        Exclusion { rule: rule.trim().to_string(), path_prefix: path.trim().to_string() }
131    }
132
133    fn covers(&self, id: u32, path: &str) -> bool {
134        let rule_ok = self.rule == "*" || self.rule == id.to_string() || self.rule.strip_suffix('*').is_some_and(|p| id.to_string().starts_with(p));
135        rule_ok && path.starts_with(&self.path_prefix)
136    }
137}
138
139/// WAF settings.
140#[derive(Debug, Clone, PartialEq)]
141pub struct WafConfig {
142    pub mode: Mode,
143    /// 1 (default): rules with very few false positives; 2 and 3 add
144    /// stricter ones.
145    pub paranoia: u8,
146    /// Anomaly score at which a request is refused (default 5).
147    pub threshold: u32,
148    /// How much of a body is inspected (default 64 KiB; 0: bodies aren't).
149    pub inspect_body_bytes: usize,
150    /// More arguments than this is a match of 920100 (default 255).
151    pub max_args: usize,
152    pub exclude: Vec<Exclusion>,
153    pub rules: Vec<CustomRule>,
154}
155
156impl Default for WafConfig {
157    fn default() -> Self {
158        WafConfig { mode: Mode::Block, paranoia: 1, threshold: 5, inspect_body_bytes: 64 * 1024, max_args: 255, exclude: Vec::new(), rules: Vec::new() }
159    }
160}
161
162/// One match.
163#[derive(Debug, Clone, PartialEq)]
164pub struct Match {
165    pub rule: u32,
166    pub score: u32,
167    /// Where: `path`, `arg:q`, `body:email`, `header:user-agent`, …
168    pub location: String,
169}
170
171/// The outcome of [`Waf::inspect`].
172#[derive(Debug, Clone, PartialEq, Default)]
173pub struct Verdict {
174    pub score: u32,
175    pub matches: Vec<Match>,
176    /// A custom `deny` rule matched.
177    pub denied: bool,
178    /// A custom `allow` rule matched: nothing else counts.
179    pub allowed: bool,
180}
181
182impl Verdict {
183    pub fn rule_ids(&self) -> Vec<u32> {
184        let mut ids: Vec<u32> = self.matches.iter().map(|m| m.rule).collect();
185        ids.sort_unstable();
186        ids.dedup();
187        ids
188    }
189}
190
191/// The engine: config plus [`inspect`](Self::inspect).
192#[derive(Debug, Clone)]
193pub struct Waf {
194    config: WafConfig,
195}
196
197/// Name/value pairs of a request's parts.
198fn form_pairs(text: &str) -> Vec<(String, String)> {
199    text.split('&')
200        .filter(|p| !p.is_empty())
201        .take(4096)
202        .map(|pair| {
203            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
204            let dec = |s: &str| crate::url::URL::percent_decode(&s.replace('+', " "));
205            (dec(k), dec(v))
206        })
207        .collect()
208}
209
210/// The string tokens of a JSON document (keys and values), bounded.
211fn json_strings(text: &str) -> Vec<String> {
212    let b = text.as_bytes();
213    let mut out = Vec::new();
214    let mut i = 0;
215    while i < b.len() && out.len() < 4096 {
216        if b[i] == b'"' {
217            let mut s = Vec::new();
218            i += 1;
219            while i < b.len() && b[i] != b'"' {
220                if b[i] == b'\\' && i + 1 < b.len() {
221                    match b[i + 1] {
222                        b'n' => s.push(b'\n'),
223                        b't' => s.push(b'\t'),
224                        b'u' if i + 5 < b.len() => {
225                            if let Some(ch) = std::str::from_utf8(&b[i + 2..i + 6]).ok().and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32) {
226                                let mut buf = [0u8; 4];
227                                s.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
228                            }
229                            i += 4;
230                        }
231                        other => s.push(other),
232                    }
233                    i += 2;
234                } else {
235                    s.push(b[i]);
236                    i += 1;
237                }
238            }
239            out.push(String::from_utf8_lossy(&s).into_owned());
240        }
241        i += 1;
242    }
243    out
244}
245
246/// The non-file fields of a multipart body: (name, value).
247fn multipart_fields(body: &[u8], content_type: &str) -> Vec<(String, String)> {
248    // The boundary is case-sensitive; the parameter name isn't.
249    let Some(boundary) = content_type.split(';').find_map(|p| {
250        let p = p.trim();
251        p.get(..9).filter(|k| k.eq_ignore_ascii_case("boundary=")).map(|_| p[9..].trim_matches('"').to_string())
252    }) else {
253        return Vec::new();
254    };
255    let text = String::from_utf8_lossy(body);
256    let delimiter = format!("--{boundary}");
257    let mut out = Vec::new();
258    for part in text.split(delimiter.as_str()).skip(1).take(256) {
259        let Some((head, value)) = part.split_once("\r\n\r\n") else { continue };
260        let head_lower = head.to_ascii_lowercase();
261        let name = head_lower.split("name=\"").nth(1).and_then(|n| n.split('"').next()).unwrap_or("").to_string();
262        if head_lower.contains("filename=") {
263            // Uploaded files: only their names.
264            if let Some(file) = head.split("filename=\"").nth(1).and_then(|n| n.split('"').next()) {
265                out.push((format!("{name}.filename"), file.to_string()));
266            }
267            continue;
268        }
269        out.push((name, value.trim_end_matches("\r\n").trim_end_matches("--").to_string()));
270    }
271    out
272}
273
274impl Waf {
275    pub fn new(config: WafConfig) -> Waf {
276        Waf { config }
277    }
278
279    pub fn config(&self) -> &WafConfig {
280        &self.config
281    }
282
283    fn enabled(&self, id: u32, path: &str) -> Option<&'static detect::Rule> {
284        let rule = detect::rule(id)?;
285        (rule.paranoia <= self.config.paranoia && !self.config.exclude.iter().any(|e| e.covers(id, path))).then_some(rule)
286    }
287
288    /// Check `request`.
289    pub fn inspect(&self, request: &Request, connection: &ConnectionInfo) -> Verdict {
290        let mut verdict = Verdict::default();
291        if self.config.mode == Mode::Off {
292            return verdict;
293        }
294        let target = &request.request_uri;
295        let (raw_path, query) = target.split_once('?').unwrap_or((target, ""));
296        let path = crate::url::URL::percent_decode(raw_path);
297        let header = |name: &str| request.header(name).map(|h| h.value.to_string()).unwrap_or_default();
298        let raw_content_type = header("Content-Type");
299        let content_type = raw_content_type.to_ascii_lowercase();
300
301        let mut args = form_pairs(query);
302        let body = if self.config.inspect_body_bytes > 0 { &request.body[..request.body.len().min(self.config.inspect_body_bytes)] } else { &[][..] };
303        let mut body_values: Vec<(String, String)> = Vec::new();
304        if !body.is_empty() {
305            if content_type.starts_with("application/x-www-form-urlencoded") {
306                args.extend(form_pairs(&String::from_utf8_lossy(body)));
307            } else if content_type.contains("json") {
308                body_values.extend(json_strings(&String::from_utf8_lossy(body)).into_iter().map(|s| ("json".to_string(), s)));
309            } else if content_type.starts_with("multipart/form-data") {
310                args.extend(multipart_fields(body, &raw_content_type));
311            } else if content_type.starts_with("text/") || content_type.contains("xml") {
312                body_values.push(("body".to_string(), String::from_utf8_lossy(body).into_owned()));
313            }
314        }
315
316        // Custom rules first: deny / allow decide on their own.
317        let ip = connection.client.ip.as_str();
318        for rule in &self.config.rules {
319            if self.custom_matches(rule, request, &path, query, &args, body, ip) {
320                match rule.action {
321                    Action::Deny => verdict.denied = true,
322                    Action::Allow => {
323                        verdict.allowed = true;
324                        return verdict;
325                    }
326                    Action::Log => verdict.matches.push(Match { rule: rule.id, score: 0, location: "custom".into() }),
327                    Action::Score(n) => {
328                        verdict.score += n;
329                        verdict.matches.push(Match { rule: rule.id, score: n, location: "custom".into() });
330                    }
331                }
332            }
333        }
334
335        let add = |verdict: &mut Verdict, hits: &[u32], location: &str| {
336            let mut seen: Vec<u32> = Vec::new();
337            for id in hits {
338                if seen.contains(id) {
339                    continue;
340                }
341                seen.push(*id);
342                if let Some(rule) = self.enabled(*id, &path) {
343                    verdict.score += rule.score;
344                    verdict.matches.push(Match { rule: *id, score: rule.score, location: location.to_string() });
345                }
346            }
347        };
348        let mut hits = Vec::new();
349        // The path: traversal, files, wrappers; and markup or SQL in it.
350        detect::value(&decode::normalize(&path), &mut hits);
351        add(&mut verdict, &hits, "path");
352        if args.len() > self.config.max_args {
353            add(&mut verdict, &[920100], "args");
354        }
355        for (name, value) in &args {
356            hits.clear();
357            detect::value(&decode::normalize(value), &mut hits);
358            if name.len() > 1 {
359                detect::value(&decode::normalize(name), &mut hits);
360            }
361            if value.len() > 8192 {
362                hits.push(920200);
363            }
364            add(&mut verdict, &hits, &format!("arg:{}", truncate(name, 64)));
365        }
366        for (name, value) in &body_values {
367            hits.clear();
368            detect::value(&decode::normalize(value), &mut hits);
369            add(&mut verdict, &hits, name);
370        }
371        // Cookies: their values.
372        for cookie in request.headers.iter().filter(|h| h.name.eq_ignore_ascii_case("cookie")) {
373            for pair in cookie.value.split(';').take(64) {
374                let (name, value) = pair.split_once('=').unwrap_or(("", pair));
375                hits.clear();
376                detect::value(&decode::normalize(value), &mut hits);
377                add(&mut verdict, &hits, &format!("cookie:{}", truncate(name.trim(), 64)));
378            }
379        }
380        // Headers: scanners and Log4Shell everywhere; SQL/XSS in Referer and
381        // User-Agent from paranoia 2.
382        let user_agent = decode::normalize(&header("User-Agent"));
383        if detect::scanner(&user_agent) {
384            add(&mut verdict, &[913100], "header:user-agent");
385        }
386        for h in request.headers.iter().take(128) {
387            if h.value.contains("${") && detect::log4shell(&decode::normalize(&h.value)) {
388                add(&mut verdict, &[944100], &format!("header:{}", h.name.to_ascii_lowercase()));
389            }
390        }
391        if self.config.paranoia >= 2 {
392            for name in ["Referer", "User-Agent"] {
393                hits.clear();
394                detect::value(&decode::normalize(&header(name)), &mut hits);
395                add(&mut verdict, &hits, &format!("header:{}", name.to_ascii_lowercase()));
396            }
397        }
398        verdict
399    }
400
401    #[allow(clippy::too_many_arguments)]
402    fn custom_matches(&self, rule: &CustomRule, request: &Request, path: &str, query: &str, args: &[(String, String)], body: &[u8], ip: &str) -> bool {
403        if rule.field == Field::Ip {
404            return rule.cidr.iter().any(|c| crate::ip_filter::matches(c, ip));
405        }
406        let values: Vec<String> = match &rule.field {
407            Field::Path => vec![path.to_string()],
408            Field::Query => vec![query.to_string()],
409            Field::Method => vec![request.method.clone()],
410            Field::Arg(name) => args.iter().filter(|(k, _)| k == name).map(|(_, v)| v.clone()).collect(),
411            Field::Args => args.iter().map(|(_, v)| v.clone()).collect(),
412            Field::Header(name) => request.headers.iter().filter(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.to_string()).collect(),
413            Field::Body => vec![String::from_utf8_lossy(body).into_owned()],
414            Field::Ip => Vec::new(),
415        };
416        values.iter().any(|v| {
417            let v = v.to_ascii_lowercase();
418            rule.contains.iter().any(|c| v.contains(&c.to_ascii_lowercase()))
419                || rule.prefix.iter().any(|p| v.starts_with(&p.to_ascii_lowercase()))
420                || rule.equals.iter().any(|e| v == e.to_ascii_lowercase())
421        })
422    }
423}
424
425fn truncate(s: &str, n: usize) -> String {
426    s.chars().take(n).collect()
427}
428
429// ── Metrics ──────────────────────────────────────────────────────────────────
430
431struct Counters {
432    blocked: AtomicU64,
433    detected: AtomicU64,
434    rules: Mutex<BTreeMap<u32, u64>>,
435}
436
437fn counters() -> &'static Counters {
438    static C: OnceLock<Counters> = OnceLock::new();
439    C.get_or_init(|| Counters { blocked: AtomicU64::new(0), detected: AtomicU64::new(0), rules: Mutex::new(BTreeMap::new()) })
440}
441
442/// `rws_waf_requests_total{action}` and `rws_waf_rule_matches_total{rule}`
443/// (appended to `/metrics`; empty until the WAF has seen a match).
444pub fn prometheus_text() -> String {
445    let c = counters();
446    let rules = c.rules.lock().unwrap_or_else(|e| e.into_inner());
447    if rules.is_empty() && c.blocked.load(Ordering::Relaxed) == 0 && c.detected.load(Ordering::Relaxed) == 0 {
448        return String::new();
449    }
450    let mut out = String::from("# HELP rws_waf_requests_total Requests the web application firewall matched, by what it did.\n# TYPE rws_waf_requests_total counter\n");
451    out.push_str(&format!("rws_waf_requests_total{{action=\"blocked\"}} {}\nrws_waf_requests_total{{action=\"detected\"}} {}\n", c.blocked.load(Ordering::Relaxed), c.detected.load(Ordering::Relaxed)));
452    out.push_str("# HELP rws_waf_rule_matches_total Matches per WAF rule.\n# TYPE rws_waf_rule_matches_total counter\n");
453    for (rule, n) in rules.iter() {
454        out.push_str(&format!("rws_waf_rule_matches_total{{rule=\"{rule}\"}} {n}\n"));
455    }
456    out
457}
458
459// ── The middleware ───────────────────────────────────────────────────────────
460
461/// The WAF in front of an application. See the [module docs](self).
462pub struct WafLayer {
463    waf: Waf,
464}
465
466impl WafLayer {
467    pub fn new(config: WafConfig) -> WafLayer {
468        WafLayer { waf: Waf::new(config) }
469    }
470}
471
472fn refused(verdict: &Verdict) -> Response {
473    let ids: Vec<String> = verdict.rule_ids().iter().map(u32::to_string).collect();
474    let detail = if verdict.denied && ids.is_empty() { "refused by a firewall rule".to_string() } else { format!("refused by the web application firewall (rules {})", ids.join(", ")) };
475    let mut response = crate::error::problem(STATUS_CODE_REASON_PHRASE.n403_forbidden, &detail);
476    response.headers.push(crate::header::Header::new("Cache-Control", "no-store"));
477    response
478}
479
480impl Middleware for WafLayer {
481    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
482        let verdict = self.waf.inspect(request, connection);
483        if verdict.allowed || (verdict.matches.is_empty() && !verdict.denied) {
484            return next.execute(request, connection);
485        }
486        let c = counters();
487        {
488            let mut rules = c.rules.lock().unwrap_or_else(|e| e.into_inner());
489            for id in verdict.rule_ids() {
490                *rules.entry(id).or_insert(0) += 1;
491            }
492        }
493        let block = verdict.denied || (self.waf.config.mode == Mode::Block && verdict.score >= self.waf.config.threshold);
494        let locations: Vec<String> = verdict.matches.iter().map(|m| format!("{}@{}", m.rule, m.location)).collect();
495        eprintln!(
496            "[waf] {} {} {} {} score={} {}",
497            if block { "blocked" } else { "detected" },
498            connection.client.ip,
499            request.method,
500            truncate(&request.request_uri, 200),
501            verdict.score,
502            locations.join(" ")
503        );
504        if block {
505            c.blocked.fetch_add(1, Ordering::Relaxed);
506            return Ok(refused(&verdict));
507        }
508        c.detected.fetch_add(1, Ordering::Relaxed);
509        next.execute(request, connection)
510    }
511
512    /// CPU only, bounded by the inspected sizes.
513    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
514        true
515    }
516
517    /// Bodies are inspected, so they arrive whole (unless body inspection
518    /// is off).
519    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
520        self.waf.config.inspect_body_bytes == 0
521    }
522}
523
524#[doc(hidden)]
525pub mod fuzz {
526    /// Fuzz entry: normalize and run every detection on arbitrary text.
527    pub fn inspect(data: &[u8]) {
528        let text = String::from_utf8_lossy(data);
529        let v = super::decode::normalize(&text);
530        let mut hits = Vec::new();
531        super::detect::value(&v, &mut hits);
532        let _ = super::detect::scanner(&v);
533        let _ = super::json_strings(&text);
534        let _ = super::multipart_fields(data, "multipart/form-data; boundary=x");
535    }
536}
537
538#[cfg(test)]
539mod tests;