Skip to main content

http_handle/
waf.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2023 - 2026 HTTP Handle
3
4//! Baseline web application firewall: request rules matched before a
5//! request is routed or served.
6//!
7//! A [`WafPolicy`] holds [`WafRule`]s, each a case-insensitive regular
8//! expression over one part of the request ([`WafTarget`]). The request
9//! target is checked both as received and percent-decoded, so
10//! `%2e%2e%2f` matches a rule written for `../`. The first rule that
11//! matches decides:
12//!
13//! - in [`WafMode::Block`] the request gets `403 Forbidden`;
14//! - in [`WafMode::ReportOnly`] it is served as usual.
15//!
16//! Either way a [`WafEvent`] is logged at `warn` level through the
17//! `log` crate with the rule id, the target, the action and the
18//! request line, and counted in [`stats`] and on `/metrics`.
19//!
20//! [`WafPolicy::baseline`] gives a starting rule set for path
21//! traversal, null bytes, VCS and environment files, script injection,
22//! SQL injection and shell command injection. It is a coarse filter,
23//! not a substitute for validating input where it is used.
24//!
25//! # Examples
26//!
27//! ```rust
28//! use http_handle::waf::{WafMode, WafPolicy, WafRule, WafTarget};
29//! use http_handle::Server;
30//!
31//! let mut rules = WafPolicy::baseline_rules();
32//! rules.push(WafRule::new("no-admin", WafTarget::Uri, r"^/admin(/|$)"));
33//! let waf = WafPolicy::new(WafMode::Block, rules).expect("valid rules");
34//!
35//! let server = Server::builder()
36//!     .address("127.0.0.1:0")
37//!     .document_root(".")
38//!     .waf(waf)
39//!     .build()
40//!     .expect("server");
41//! assert!(server.waf().is_some());
42//! ```
43
44use crate::request::Request;
45use regex::{Regex, RegexBuilder};
46use serde::{Deserialize, Serialize};
47use std::sync::atomic::{AtomicUsize, Ordering};
48use thiserror::Error;
49
50/// What happens when a rule matches.
51#[derive(
52    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
53)]
54#[serde(rename_all = "snake_case")]
55pub enum WafMode {
56    /// Deny the request with `403 Forbidden`.
57    #[default]
58    Block,
59    /// Serve the request and record the match.
60    ReportOnly,
61}
62
63/// The part of the request a rule inspects.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum WafTarget {
67    /// The request target (path and query), raw and percent-decoded.
68    Uri,
69    /// The request method.
70    Method,
71    /// One header, by name (case-insensitive).
72    Header(String),
73    /// Every header value.
74    AnyHeader,
75}
76
77/// One rule definition.
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct WafRule {
80    /// Unique id, reported in events.
81    pub id: String,
82    /// What the rule inspects.
83    pub target: WafTarget,
84    /// Regular expression, matched case-insensitively.
85    pub pattern: String,
86}
87
88impl WafRule {
89    /// Creates a rule.
90    pub fn new(id: &str, target: WafTarget, pattern: &str) -> Self {
91        Self {
92            id: id.to_string(),
93            target,
94            pattern: pattern.to_string(),
95        }
96    }
97}
98
99/// Why a rule set was rejected.
100#[derive(Clone, Debug, PartialEq, Eq, Error)]
101pub enum WafError {
102    /// A rule has an empty id.
103    #[error("WAF rule id must not be empty")]
104    EmptyId,
105    /// Two rules share an id.
106    #[error("duplicate WAF rule id `{0}`")]
107    DuplicateId(String),
108    /// A header target names no header.
109    #[error("WAF rule `{0}`: header target needs a header name")]
110    EmptyHeaderName(String),
111    /// A pattern is not a valid regular expression.
112    #[error("WAF rule `{id}`: invalid pattern: {reason}")]
113    InvalidPattern {
114        /// Rule id.
115        id: String,
116        /// The regex compiler's message.
117        reason: String,
118    },
119}
120
121/// The action taken for a matched rule.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum WafAction {
124    /// The request was denied.
125    Blocked,
126    /// The request was served and the match recorded.
127    Reported,
128}
129
130/// A rule match, as logged.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct WafEvent {
133    /// Id of the rule that matched.
134    pub rule_id: String,
135    /// The inspected part, for example `uri` or `header:user-agent`.
136    pub target: String,
137    /// What was done.
138    pub action: WafAction,
139    /// Request method.
140    pub method: String,
141    /// Request target as received.
142    pub path: String,
143}
144
145impl WafEvent {
146    /// One `key=value` line, the form written to the log.
147    pub fn to_log_line(&self) -> String {
148        let action = match self.action {
149            WafAction::Blocked => "blocked",
150            WafAction::Reported => "reported",
151        };
152        format!(
153            "waf rule={} target={} action={action} method={} path={:?}",
154            self.rule_id, self.target, self.method, self.path
155        )
156    }
157}
158
159#[derive(Clone, Debug)]
160struct CompiledRule {
161    rule: WafRule,
162    regex: Regex,
163}
164
165/// Configuration form of a [`WafPolicy`], used for serde.
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
167pub struct WafConfig {
168    /// Block or report.
169    #[serde(default)]
170    pub mode: WafMode,
171    /// Rules, tried in order.
172    pub rules: Vec<WafRule>,
173}
174
175/// A validated rule set and its mode.
176///
177/// Serialises as a [`WafConfig`]; deserialising compiles and validates
178/// the rules again.
179#[derive(Clone, Debug, Serialize, Deserialize)]
180#[serde(try_from = "WafConfig", into = "WafConfig")]
181pub struct WafPolicy {
182    mode: WafMode,
183    rules: Vec<CompiledRule>,
184}
185
186impl PartialEq for WafPolicy {
187    fn eq(&self, other: &Self) -> bool {
188        self.mode == other.mode && self.rules().eq(other.rules())
189    }
190}
191
192impl Eq for WafPolicy {}
193
194impl WafPolicy {
195    /// Compiles and validates `rules`.
196    ///
197    /// # Errors
198    ///
199    /// Returns a [`WafError`] for an empty or duplicate id, a header
200    /// target without a name, or a pattern that does not compile.
201    pub fn new(
202        mode: WafMode,
203        rules: Vec<WafRule>,
204    ) -> Result<Self, WafError> {
205        let mut compiled: Vec<CompiledRule> =
206            Vec::with_capacity(rules.len());
207        for rule in rules {
208            validate(&rule, &compiled)?;
209            let regex = RegexBuilder::new(&rule.pattern)
210                .case_insensitive(true)
211                .build()
212                .map_err(|e| WafError::InvalidPattern {
213                    id: rule.id.clone(),
214                    reason: e.to_string(),
215                })?;
216            compiled.push(CompiledRule { rule, regex });
217        }
218        Ok(Self {
219            mode,
220            rules: compiled,
221        })
222    }
223
224    /// The baseline rule set in the given mode.
225    pub fn baseline(mode: WafMode) -> Self {
226        Self::new(mode, Self::baseline_rules())
227            .expect("baseline rules are valid")
228    }
229
230    /// The baseline rules, to extend before calling [`WafPolicy::new`].
231    pub fn baseline_rules() -> Vec<WafRule> {
232        use WafTarget::{AnyHeader, Uri};
233        [
234            ("path-traversal", Uri, r"(^|[/\\])\.\.([/\\]|$)"),
235            ("null-byte", Uri, r"\x00"),
236            ("sensitive-file", Uri, r"/\.(git|svn|hg|env|htaccess|htpasswd)(/|$)"),
237            ("script-injection", Uri, r"<\s*script|javascript:|on(error|load)\s*="),
238            ("sql-injection", Uri, r"\bunion\b.+\bselect\b|'\s*or\s+'?\d+'?\s*=\s*'?\d+|;\s*drop\s+table\b"),
239            ("command-injection", Uri, r"[;|`]\s*(cat|ls|id|wget|curl|sh|bash)\b|\$\("),
240            ("header-injection", AnyHeader, r"[\r\n]|\$\{jndi:"),
241        ]
242        .into_iter()
243        .map(|(id, target, pattern)| WafRule::new(id, target, pattern))
244        .collect()
245    }
246
247    /// The mode.
248    pub fn mode(&self) -> WafMode {
249        self.mode
250    }
251
252    /// The rules, in matching order.
253    pub fn rules(&self) -> impl Iterator<Item = &WafRule> {
254        self.rules.iter().map(|c| &c.rule)
255    }
256
257    /// The first rule `request` matches, as an event with the action
258    /// this policy's mode takes. Does not log or count.
259    pub fn inspect(&self, request: &Request) -> Option<WafEvent> {
260        let decoded = percent_decode(request.path());
261        let compiled = self
262            .rules
263            .iter()
264            .find(|c| matches(c, request, &decoded))?;
265        Some(WafEvent {
266            rule_id: compiled.rule.id.clone(),
267            target: target_label(&compiled.rule.target),
268            action: match self.mode {
269                WafMode::Block => WafAction::Blocked,
270                WafMode::ReportOnly => WafAction::Reported,
271            },
272            method: request.method().to_string(),
273            path: request.path().to_string(),
274        })
275    }
276}
277
278impl TryFrom<WafConfig> for WafPolicy {
279    type Error = WafError;
280
281    fn try_from(config: WafConfig) -> Result<Self, Self::Error> {
282        Self::new(config.mode, config.rules)
283    }
284}
285
286impl From<WafPolicy> for WafConfig {
287    fn from(policy: WafPolicy) -> Self {
288        Self {
289            mode: policy.mode,
290            rules: policy.rules.into_iter().map(|c| c.rule).collect(),
291        }
292    }
293}
294
295static BLOCKED: AtomicUsize = AtomicUsize::new(0);
296static REPORTED: AtomicUsize = AtomicUsize::new(0);
297
298/// Counts of WAF matches since the process started.
299#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
300pub struct WafStats {
301    /// Requests denied.
302    pub blocked: usize,
303    /// Matches recorded in report-only mode.
304    pub reported: usize,
305}
306
307/// Returns the WAF counters.
308pub fn stats() -> WafStats {
309    WafStats {
310        blocked: BLOCKED.load(Ordering::Relaxed),
311        reported: REPORTED.load(Ordering::Relaxed),
312    }
313}
314
315/// Prometheus text lines for the `/metrics` endpoint.
316pub(crate) fn metrics_lines() -> String {
317    let s = stats();
318    format!(
319        "http_handle_waf_total{{action=\"blocked\"}} {}\nhttp_handle_waf_total{{action=\"reported\"}} {}\n",
320        s.blocked, s.reported
321    )
322}
323
324/// Logs and counts `event`.
325pub(crate) fn record(event: &WafEvent) {
326    log::warn!("{}", event.to_log_line());
327    let counter = match event.action {
328        WafAction::Blocked => &BLOCKED,
329        WafAction::Reported => &REPORTED,
330    };
331    let _ = counter.fetch_add(1, Ordering::Relaxed);
332}
333
334fn validate(
335    rule: &WafRule,
336    earlier: &[CompiledRule],
337) -> Result<(), WafError> {
338    if rule.id.is_empty() {
339        return Err(WafError::EmptyId);
340    }
341    if earlier.iter().any(|c| c.rule.id == rule.id) {
342        return Err(WafError::DuplicateId(rule.id.clone()));
343    }
344    match &rule.target {
345        WafTarget::Header(name) if name.is_empty() => {
346            Err(WafError::EmptyHeaderName(rule.id.clone()))
347        }
348        _ => Ok(()),
349    }
350}
351
352fn matches(
353    compiled: &CompiledRule,
354    request: &Request,
355    decoded: &str,
356) -> bool {
357    let regex = &compiled.regex;
358    match &compiled.rule.target {
359        WafTarget::Uri => {
360            regex.is_match(request.path()) || regex.is_match(decoded)
361        }
362        WafTarget::Method => regex.is_match(request.method()),
363        WafTarget::Header(name) => {
364            request.header(name).is_some_and(|v| regex.is_match(v))
365        }
366        WafTarget::AnyHeader => {
367            request.headers().iter().any(|(_, v)| regex.is_match(v))
368        }
369    }
370}
371
372fn target_label(target: &WafTarget) -> String {
373    match target {
374        WafTarget::Uri => "uri".into(),
375        WafTarget::Method => "method".into(),
376        WafTarget::Header(name) => {
377            format!("header:{}", name.to_ascii_lowercase())
378        }
379        WafTarget::AnyHeader => "any_header".into(),
380    }
381}
382
383/// Decodes `%XX` escapes; invalid escapes are kept as written.
384fn percent_decode(input: &str) -> String {
385    let bytes = input.as_bytes();
386    let mut out = Vec::with_capacity(bytes.len());
387    let mut i = 0;
388    while i < bytes.len() {
389        let hex = bytes.get(i + 1..i + 3).and_then(|h| {
390            u8::from_str_radix(std::str::from_utf8(h).ok()?, 16).ok()
391        });
392        match (bytes[i], hex) {
393            (b'%', Some(byte)) => {
394                out.push(byte);
395                i += 3;
396            }
397            (byte, _) => {
398                out.push(byte);
399                i += 1;
400            }
401        }
402    }
403    String::from_utf8_lossy(&out).into_owned()
404}