Skip to main content

bext_waf/rules/
custom.rs

1//! User-defined custom rules — composable match conditions on path globs,
2//! HTTP methods, and header presence/absence/value, with block or log actions.
3
4use serde::{Deserialize, Serialize};
5
6use crate::{WafDecision, WafRequest};
7
8/// Action to take when a custom rule matches.
9#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum CustomRuleAction {
12    /// Block the request with an HTTP status code.
13    #[default]
14    Block,
15    /// Log the match but allow the request.
16    Log,
17}
18
19/// Conditions for matching a request against a custom rule.
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct MatchConfig {
22    /// Glob-style path pattern (supports `*` and `**`).
23    /// `None` means match any path.
24    pub path: Option<String>,
25    /// HTTP method to match (e.g. "POST"). `None` means match any method.
26    pub method: Option<String>,
27    /// Header that must be present.
28    pub header_present: Option<String>,
29    /// Header that must be absent.
30    pub header_missing: Option<String>,
31    /// Header that must have a specific value: (name, value).
32    pub header_value: Option<(String, String)>,
33}
34
35/// A user-defined WAF rule.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CustomRule {
38    pub name: String,
39    pub match_config: MatchConfig,
40    #[serde(default)]
41    pub action: CustomRuleAction,
42    /// HTTP status code for block action (default: 403).
43    #[serde(default = "default_status")]
44    pub status: u16,
45    /// Human-readable reason.
46    #[serde(default)]
47    pub reason: Option<String>,
48}
49
50fn default_status() -> u16 {
51    403
52}
53
54/// A collection of custom rules, evaluated in order.
55pub struct CustomRuleSet {
56    rules: Vec<CustomRule>,
57}
58
59impl CustomRuleSet {
60    pub fn new(rules: Vec<CustomRule>) -> Self {
61        Self { rules }
62    }
63
64    pub fn rules(&self) -> &[CustomRule] {
65        &self.rules
66    }
67
68    /// Evaluate all custom rules against a request.
69    /// Returns the first matching block decision, or `None`.
70    pub fn check(&self, req: &WafRequest) -> Option<WafDecision> {
71        for rule in &self.rules {
72            if matches_rule(rule, req) {
73                match rule.action {
74                    CustomRuleAction::Block => {
75                        let reason = rule
76                            .reason
77                            .clone()
78                            .unwrap_or_else(|| format!("blocked by custom rule: {}", rule.name));
79                        return Some(WafDecision::Block {
80                            status: rule.status,
81                            reason,
82                            rule: format!("custom:{}", rule.name),
83                        });
84                    }
85                    CustomRuleAction::Log => {
86                        tracing::info!(
87                            rule = %rule.name,
88                            path = %req.path,
89                            method = %req.method,
90                            "custom rule matched (log-only)"
91                        );
92                        // Continue evaluation; log-only doesn't block.
93                    }
94                }
95            }
96        }
97        None
98    }
99}
100
101fn matches_rule(rule: &CustomRule, req: &WafRequest) -> bool {
102    // Check method.
103    if let Some(ref m) = rule.match_config.method {
104        if !m.eq_ignore_ascii_case(&req.method) {
105            return false;
106        }
107    }
108
109    // Check path glob.
110    if let Some(ref pattern) = rule.match_config.path {
111        if !glob_match(pattern, &req.path) {
112            return false;
113        }
114    }
115
116    // Check header_present.
117    if let Some(ref hdr) = rule.match_config.header_present {
118        let key = hdr.to_lowercase();
119        if !req.headers.keys().any(|k| k.to_lowercase() == key) {
120            return false;
121        }
122    }
123
124    // Check header_missing.
125    if let Some(ref hdr) = rule.match_config.header_missing {
126        let key = hdr.to_lowercase();
127        if req.headers.keys().any(|k| k.to_lowercase() == key) {
128            return false;
129        }
130    }
131
132    // Check header_value.
133    if let Some((ref hdr_name, ref hdr_val)) = rule.match_config.header_value {
134        let key = hdr_name.to_lowercase();
135        let found = req
136            .headers
137            .iter()
138            .any(|(k, v)| k.to_lowercase() == key && v == hdr_val);
139        if !found {
140            return false;
141        }
142    }
143
144    true
145}
146
147/// Simple glob matching supporting `*` (any segment) and `**` (any path depth).
148fn glob_match(pattern: &str, path: &str) -> bool {
149    // Split both by '/'.
150    let pat_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
151    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
152    glob_match_parts(&pat_parts, &path_parts)
153}
154
155fn glob_match_parts(pattern: &[&str], path: &[&str]) -> bool {
156    if pattern.is_empty() {
157        return path.is_empty();
158    }
159
160    if pattern[0] == "**" {
161        // ** matches zero or more path segments.
162        let rest_pattern = &pattern[1..];
163        for i in 0..=path.len() {
164            if glob_match_parts(rest_pattern, &path[i..]) {
165                return true;
166            }
167        }
168        return false;
169    }
170
171    if path.is_empty() {
172        return false;
173    }
174
175    if segment_match(pattern[0], path[0]) {
176        glob_match_parts(&pattern[1..], &path[1..])
177    } else {
178        false
179    }
180}
181
182fn segment_match(pattern: &str, segment: &str) -> bool {
183    if pattern == "*" {
184        return true;
185    }
186    // Simple wildcard: pattern can contain * meaning any substring.
187    if pattern.contains('*') {
188        let parts: Vec<&str> = pattern.split('*').collect();
189        let mut pos = 0;
190        for (i, part) in parts.iter().enumerate() {
191            if part.is_empty() {
192                continue;
193            }
194            match segment[pos..].find(part) {
195                Some(found) => {
196                    if i == 0 && found != 0 {
197                        // First part must match at start.
198                        return false;
199                    }
200                    pos += found + part.len();
201                }
202                None => return false,
203            }
204        }
205        // If last part is non-empty, segment must end with it.
206        if let Some(last) = parts.last() {
207            if !last.is_empty() && !segment.ends_with(last) {
208                return false;
209            }
210        }
211        true
212    } else {
213        pattern == segment
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn make_req(method: &str, path: &str, headers: Vec<(&str, &str)>) -> WafRequest {
222        WafRequest {
223            client_ip: "127.0.0.1".parse().unwrap(),
224            method: method.into(),
225            path: path.into(),
226            query: None,
227            headers: headers
228                .into_iter()
229                .map(|(k, v)| (k.into(), v.into()))
230                .collect(),
231            body: None,
232            user_agent: None,
233        }
234    }
235
236    #[test]
237    fn block_by_path() {
238        let rules = CustomRuleSet::new(vec![CustomRule {
239            name: "block-admin".into(),
240            match_config: MatchConfig {
241                path: Some("/admin/**".into()),
242                ..Default::default()
243            },
244            action: CustomRuleAction::Block,
245            status: 403,
246            reason: None,
247        }]);
248
249        assert!(rules
250            .check(&make_req("GET", "/admin/settings", vec![]))
251            .is_some());
252        assert!(rules
253            .check(&make_req("GET", "/api/users", vec![]))
254            .is_none());
255    }
256
257    #[test]
258    fn block_by_method() {
259        let rules = CustomRuleSet::new(vec![CustomRule {
260            name: "block-delete".into(),
261            match_config: MatchConfig {
262                method: Some("DELETE".into()),
263                ..Default::default()
264            },
265            action: CustomRuleAction::Block,
266            status: 405,
267            reason: Some("DELETE not allowed".into()),
268        }]);
269
270        assert!(rules
271            .check(&make_req("DELETE", "/api/resource", vec![]))
272            .is_some());
273        assert!(rules
274            .check(&make_req("GET", "/api/resource", vec![]))
275            .is_none());
276    }
277
278    #[test]
279    fn block_by_header_present() {
280        let rules = CustomRuleSet::new(vec![CustomRule {
281            name: "require-no-debug".into(),
282            match_config: MatchConfig {
283                header_present: Some("X-Debug".into()),
284                ..Default::default()
285            },
286            action: CustomRuleAction::Block,
287            status: 403,
288            reason: None,
289        }]);
290
291        assert!(rules
292            .check(&make_req("GET", "/", vec![("X-Debug", "true")]))
293            .is_some());
294        assert!(rules.check(&make_req("GET", "/", vec![])).is_none());
295    }
296
297    #[test]
298    fn block_by_header_missing() {
299        let rules = CustomRuleSet::new(vec![CustomRule {
300            name: "require-auth".into(),
301            match_config: MatchConfig {
302                header_missing: Some("Authorization".into()),
303                ..Default::default()
304            },
305            action: CustomRuleAction::Block,
306            status: 401,
307            reason: Some("Authorization required".into()),
308        }]);
309
310        assert!(rules.check(&make_req("GET", "/api/data", vec![])).is_some());
311        assert!(rules
312            .check(&make_req(
313                "GET",
314                "/api/data",
315                vec![("Authorization", "Bearer token")]
316            ))
317            .is_none());
318    }
319
320    #[test]
321    fn block_by_header_value() {
322        let rules = CustomRuleSet::new(vec![CustomRule {
323            name: "block-bad-origin".into(),
324            match_config: MatchConfig {
325                header_value: Some(("Origin".into(), "http://evil.com".into())),
326                ..Default::default()
327            },
328            action: CustomRuleAction::Block,
329            status: 403,
330            reason: None,
331        }]);
332
333        assert!(rules
334            .check(&make_req("GET", "/", vec![("Origin", "http://evil.com")]))
335            .is_some());
336        assert!(rules
337            .check(&make_req("GET", "/", vec![("Origin", "http://good.com")]))
338            .is_none());
339    }
340
341    #[test]
342    fn log_action_does_not_block() {
343        let rules = CustomRuleSet::new(vec![CustomRule {
344            name: "log-api".into(),
345            match_config: MatchConfig {
346                path: Some("/api/**".into()),
347                ..Default::default()
348            },
349            action: CustomRuleAction::Log,
350            status: 200,
351            reason: None,
352        }]);
353
354        assert!(rules
355            .check(&make_req("GET", "/api/users", vec![]))
356            .is_none());
357    }
358
359    #[test]
360    fn combined_conditions() {
361        let rules = CustomRuleSet::new(vec![CustomRule {
362            name: "block-post-admin".into(),
363            match_config: MatchConfig {
364                path: Some("/admin/**".into()),
365                method: Some("POST".into()),
366                ..Default::default()
367            },
368            action: CustomRuleAction::Block,
369            status: 403,
370            reason: None,
371        }]);
372
373        // POST to admin: blocked.
374        assert!(rules
375            .check(&make_req("POST", "/admin/settings", vec![]))
376            .is_some());
377        // GET to admin: not blocked (method mismatch).
378        assert!(rules
379            .check(&make_req("GET", "/admin/settings", vec![]))
380            .is_none());
381        // POST to non-admin: not blocked.
382        assert!(rules
383            .check(&make_req("POST", "/api/users", vec![]))
384            .is_none());
385    }
386
387    #[test]
388    fn evaluation_order_first_match_wins() {
389        let rules = CustomRuleSet::new(vec![
390            CustomRule {
391                name: "allow-health".into(),
392                match_config: MatchConfig {
393                    path: Some("/health".into()),
394                    ..Default::default()
395                },
396                action: CustomRuleAction::Log, // log only, doesn't block
397                status: 200,
398                reason: None,
399            },
400            CustomRule {
401                name: "block-all".into(),
402                match_config: MatchConfig::default(), // matches everything
403                action: CustomRuleAction::Block,
404                status: 403,
405                reason: None,
406            },
407        ]);
408
409        // /health matches log rule first (no block), then block-all blocks it.
410        assert!(rules.check(&make_req("GET", "/health", vec![])).is_some());
411    }
412
413    #[test]
414    fn glob_wildcard_single_segment() {
415        let rules = CustomRuleSet::new(vec![CustomRule {
416            name: "block-user-export".into(),
417            match_config: MatchConfig {
418                path: Some("/api/*/export".into()),
419                ..Default::default()
420            },
421            action: CustomRuleAction::Block,
422            status: 403,
423            reason: None,
424        }]);
425
426        assert!(rules
427            .check(&make_req("GET", "/api/users/export", vec![]))
428            .is_some());
429        assert!(rules
430            .check(&make_req("GET", "/api/orders/export", vec![]))
431            .is_some());
432        assert!(rules
433            .check(&make_req("GET", "/api/users/list", vec![]))
434            .is_none());
435    }
436
437    #[test]
438    fn glob_double_star_matches_deep() {
439        assert!(glob_match("/admin/**", "/admin/a/b/c"));
440        assert!(glob_match("/admin/**", "/admin"));
441        assert!(!glob_match("/admin/**", "/api/admin"));
442    }
443
444    #[test]
445    fn glob_exact_match() {
446        assert!(glob_match("/health", "/health"));
447        assert!(!glob_match("/health", "/healthz"));
448    }
449}