Skip to main content

bext_waf/
lib.rs

1//! `bext-waf` — Web Application Firewall for the bext server.
2//!
3//! Provides IP filtering (CIDR), geo-blocking, request inspection (SQLi/XSS/traversal/scanner),
4//! bot detection, DDoS mitigation, enhanced rate limiting, and audit logging.
5//!
6//! All regex patterns are compiled once via `OnceLock` — zero per-request compilation cost.
7
8pub mod audit;
9pub mod bot;
10pub mod ddos;
11pub mod geo;
12pub mod ip_filter;
13pub mod rate_limit;
14pub mod rules;
15
16use std::collections::HashMap;
17use std::net::IpAddr;
18
19use chrono::Utc;
20use serde::{Deserialize, Serialize};
21
22// Re-export key types.
23pub use audit::{WafAuditLog, WafAuditStats, WafEvent};
24pub use bot::{BotConfig, BotDetector, BotMode};
25pub use ddos::{DdosConfig, DdosGuard};
26pub use geo::{GeoBlocker, GeoConfig, GeoMode};
27pub use ip_filter::{IpFilter, IpFilterConfig, IpFilterMode};
28pub use rate_limit::{EnhancedRateLimiter, RateLimitRule};
29pub use rules::custom::{CustomRule, CustomRuleAction, MatchConfig};
30pub use rules::{RuleConfig, RuleEngine};
31
32/// The decision made by the WAF for a given request.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum WafDecision {
36    /// Allow the request to proceed.
37    Allow,
38    /// Block the request.
39    Block {
40        status: u16,
41        reason: String,
42        rule: String,
43    },
44    /// Rate-limit the request.
45    RateLimit { retry_after: u64 },
46    /// Issue a challenge (e.g. JS challenge for bot detection).
47    Challenge { html: String },
48}
49
50/// A protocol-agnostic representation of an HTTP request.
51#[derive(Debug, Clone)]
52pub struct WafRequest {
53    pub client_ip: IpAddr,
54    pub method: String,
55    pub path: String,
56    pub query: Option<String>,
57    pub headers: HashMap<String, String>,
58    pub body: Option<String>,
59    pub user_agent: Option<String>,
60}
61
62/// WAF statistics.
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct WafStats {
65    pub total_requests: u64,
66    pub allowed: u64,
67    pub blocked: u64,
68    pub rate_limited: u64,
69    pub challenged: u64,
70    pub audit: WafAuditStats,
71}
72
73/// Full WAF configuration.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct WafConfig {
76    /// Whether the WAF is enabled.
77    #[serde(default = "default_true")]
78    pub enabled: bool,
79    #[serde(default)]
80    pub ip_filter: IpFilterConfig,
81    #[serde(default)]
82    pub geo: GeoConfig,
83    #[serde(default)]
84    pub rules: RuleConfig,
85    #[serde(default)]
86    pub custom_rules: Vec<CustomRule>,
87    #[serde(default)]
88    pub bot: BotConfig,
89    #[serde(default)]
90    pub ddos: DdosConfig,
91    #[serde(default)]
92    pub rate_limit_rules: Vec<RateLimitRule>,
93    /// Path to MaxMind GeoLite2-Country.mmdb file.
94    #[serde(default)]
95    pub geoip_db_path: Option<String>,
96}
97
98fn default_true() -> bool {
99    true
100}
101
102impl Default for WafConfig {
103    fn default() -> Self {
104        Self {
105            enabled: true,
106            ip_filter: IpFilterConfig::default(),
107            geo: GeoConfig::default(),
108            rules: RuleConfig::default(),
109            custom_rules: Vec::new(),
110            bot: BotConfig::default(),
111            ddos: DdosConfig::default(),
112            rate_limit_rules: Vec::new(),
113            geoip_db_path: None,
114        }
115    }
116}
117
118/// The main WAF engine composing all sub-systems.
119pub struct WafEngine {
120    enabled: bool,
121    ip_filter: IpFilter,
122    geo_blocker: GeoBlocker,
123    rule_engine: RuleEngine,
124    bot_detector: BotDetector,
125    ddos_guard: DdosGuard,
126    rate_limiter: EnhancedRateLimiter,
127    audit_log: WafAuditLog,
128    stats: parking_lot::Mutex<WafStats>,
129}
130
131impl WafEngine {
132    /// Create a new WAF engine from configuration.
133    pub fn new(config: WafConfig) -> anyhow::Result<Self> {
134        let geo_blocker = match config.geoip_db_path {
135            Some(ref path) if config.geo.enabled => GeoBlocker::new(config.geo.clone(), path)?,
136            _ => GeoBlocker::disabled(),
137        };
138
139        Ok(Self {
140            enabled: config.enabled,
141            ip_filter: IpFilter::new(config.ip_filter),
142            geo_blocker,
143            rule_engine: RuleEngine::new(config.rules, config.custom_rules),
144            bot_detector: BotDetector::new(config.bot),
145            ddos_guard: DdosGuard::new(config.ddos),
146            rate_limiter: EnhancedRateLimiter::new(config.rate_limit_rules),
147            audit_log: WafAuditLog::new(),
148            stats: parking_lot::Mutex::new(WafStats::default()),
149        })
150    }
151
152    /// Check a request through all WAF layers.
153    /// Returns the first non-Allow decision, or `WafDecision::Allow`.
154    pub fn check(&self, req: &WafRequest) -> WafDecision {
155        self.check_impl(req, false).0
156    }
157
158    /// Check a request and also return rate-limit response headers if applicable.
159    pub fn check_with_headers(&self, req: &WafRequest) -> (WafDecision, Vec<(String, String)>) {
160        self.check_impl(req, true)
161    }
162
163    /// Shared implementation for `check()` and `check_with_headers()`.
164    fn check_impl(
165        &self,
166        req: &WafRequest,
167        return_headers: bool,
168    ) -> (WafDecision, Vec<(String, String)>) {
169        if !self.enabled {
170            return (WafDecision::Allow, vec![]);
171        }
172
173        {
174            let mut s = self.stats.lock();
175            s.total_requests += 1;
176        }
177
178        // 1. IP filter (fastest check -- pure network match).
179        if let Some(decision) = self.ip_filter.check(req.client_ip) {
180            self.record_decision(req, &decision);
181            return (decision, vec![]);
182        }
183
184        // 2. Geo-blocking (uses real_ip_header if configured).
185        if let Some(decision) = self.geo_blocker.check_request(req) {
186            self.record_decision(req, &decision);
187            return (decision, vec![]);
188        }
189
190        // 3. DDoS guard (connection limits, body/header size).
191        if let Some(decision) = self.ddos_guard.check(req) {
192            self.record_decision(req, &decision);
193            return (decision, vec![]);
194        }
195
196        // 4. Rate limiting.
197        if let Some((decision, headers)) = self.rate_limiter.check(req) {
198            self.record_decision(req, &decision);
199            let hdrs = if return_headers { headers } else { vec![] };
200            return (decision, hdrs);
201        }
202
203        // 5. Bot detection.
204        if let Some(decision) = self.bot_detector.check(req) {
205            self.record_decision(req, &decision);
206            return (decision, vec![]);
207        }
208
209        // 6. Rule engine (SQLi, XSS, traversal, shell injection, protocol violations, scanner, custom rules).
210        if let Some(decision) = self.rule_engine.inspect(req) {
211            self.record_decision(req, &decision);
212            return (decision, vec![]);
213        }
214
215        // All checks passed.
216        {
217            let mut s = self.stats.lock();
218            s.allowed += 1;
219        }
220        (WafDecision::Allow, vec![])
221    }
222
223    /// Record a DDoS connection start.
224    pub fn record_connection(&self, ip: IpAddr) {
225        self.ddos_guard.record_connection(ip);
226    }
227
228    /// Record a DDoS connection end.
229    pub fn release_connection(&self, ip: IpAddr) {
230        self.ddos_guard.release_connection(ip);
231    }
232
233    /// Get current WAF statistics.
234    pub fn stats(&self) -> WafStats {
235        let mut s = self.stats.lock().clone();
236        s.audit = self.audit_log.stats();
237        s
238    }
239
240    /// Get the audit log.
241    pub fn audit_log(&self) -> &WafAuditLog {
242        &self.audit_log
243    }
244
245    /// Get recent audit events.
246    pub fn recent_events(&self, count: usize) -> Vec<WafEvent> {
247        self.audit_log.recent(count)
248    }
249
250    /// Get Prometheus metrics.
251    pub fn prometheus_metrics(&self) -> String {
252        let s = self.stats.lock();
253        let mut out = self.audit_log.format_prometheus();
254
255        out.push_str("# HELP waf_requests_total Total requests processed\n");
256        out.push_str("# TYPE waf_requests_total counter\n");
257        out.push_str(&format!("waf_requests_total {}\n", s.total_requests));
258
259        out.push_str("# HELP waf_requests_allowed Total requests allowed\n");
260        out.push_str("# TYPE waf_requests_allowed counter\n");
261        out.push_str(&format!("waf_requests_allowed {}\n", s.allowed));
262
263        out
264    }
265
266    /// Hot-reload the IP filter configuration.
267    pub fn reload_ip_filter(&self, config: IpFilterConfig) {
268        self.ip_filter.reload(config);
269    }
270
271    /// Periodically clean up stale state (connection tracking, rate-limit buckets).
272    pub fn cleanup(&self) {
273        self.ddos_guard.cleanup(300); // 5 minutes
274        self.rate_limiter
275            .cleanup(std::time::Duration::from_secs(600)); // 10 minutes
276    }
277
278    fn record_decision(&self, req: &WafRequest, decision: &WafDecision) {
279        let (action, rule, reason) = match decision {
280            WafDecision::Allow => return,
281            WafDecision::Block { rule, reason, .. } => ("block", rule.as_str(), reason.as_str()),
282            WafDecision::RateLimit { retry_after: _ } => {
283                let mut s = self.stats.lock();
284                s.rate_limited += 1;
285                ("rate_limit", "rate_limit", "")
286            }
287            WafDecision::Challenge { .. } => {
288                let mut s = self.stats.lock();
289                s.challenged += 1;
290                ("challenge", "bot_detection", "JS challenge issued")
291            }
292        };
293
294        // Update block stats (rate_limit and challenge already updated above).
295        if action == "block" {
296            let mut s = self.stats.lock();
297            s.blocked += 1;
298        }
299
300        self.audit_log.record(WafEvent {
301            timestamp: Utc::now(),
302            client_ip: req.client_ip,
303            path: req.path.clone(),
304            rule: rule.to_string(),
305            action: action.to_string(),
306            details: reason.to_string(),
307            request_id: None,
308        });
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn make_req(ip: &str, method: &str, path: &str) -> WafRequest {
317        WafRequest {
318            client_ip: ip.parse().unwrap(),
319            method: method.into(),
320            path: path.into(),
321            query: None,
322            headers: {
323                let mut h = HashMap::new();
324                h.insert("Accept".into(), "text/html".into());
325                h.insert("Accept-Language".into(), "en-US".into());
326                h.insert("Accept-Encoding".into(), "gzip".into());
327                h
328            },
329            body: None,
330            user_agent: Some("Mozilla/5.0 Chrome/120.0".into()),
331        }
332    }
333
334    fn make_req_minimal(ip: &str, path: &str) -> WafRequest {
335        WafRequest {
336            client_ip: ip.parse().unwrap(),
337            method: "GET".into(),
338            path: path.into(),
339            query: None,
340            headers: HashMap::new(),
341            body: None,
342            user_agent: None,
343        }
344    }
345
346    #[test]
347    fn disabled_engine_allows_all() {
348        let config = WafConfig {
349            enabled: false,
350            ..Default::default()
351        };
352        let engine = WafEngine::new(config).unwrap();
353        let req = make_req_minimal("10.0.0.1", "/../../../etc/passwd");
354        assert_eq!(engine.check(&req), WafDecision::Allow);
355    }
356
357    #[test]
358    fn default_config_allows_clean_request() {
359        let engine = WafEngine::new(WafConfig::default()).unwrap();
360        let req = make_req("10.0.0.1", "GET", "/api/users");
361        assert_eq!(engine.check(&req), WafDecision::Allow);
362    }
363
364    #[test]
365    fn blocks_sqli_in_path() {
366        let engine = WafEngine::new(WafConfig::default()).unwrap();
367        let mut req = make_req("10.0.0.1", "GET", "/search");
368        req.query = Some("q=1 UNION SELECT * FROM users".into());
369        match engine.check(&req) {
370            WafDecision::Block { rule, .. } => assert_eq!(rule, "sql_injection"),
371            other => panic!("expected Block, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn blocks_xss_in_body() {
377        let engine = WafEngine::new(WafConfig::default()).unwrap();
378        let mut req = make_req("10.0.0.1", "POST", "/comment");
379        req.body = Some("<script>alert(1)</script>".into());
380        match engine.check(&req) {
381            WafDecision::Block { rule, .. } => assert_eq!(rule, "xss"),
382            other => panic!("expected Block, got {other:?}"),
383        }
384    }
385
386    #[test]
387    fn blocks_traversal() {
388        let engine = WafEngine::new(WafConfig::default()).unwrap();
389        let req = make_req("10.0.0.1", "GET", "/static/../../etc/passwd");
390        match engine.check(&req) {
391            WafDecision::Block { rule, .. } => assert_eq!(rule, "path_traversal"),
392            other => panic!("expected Block, got {other:?}"),
393        }
394    }
395
396    #[test]
397    fn blocks_scanner_ua() {
398        let engine = WafEngine::new(WafConfig::default()).unwrap();
399        let mut req = make_req("10.0.0.1", "GET", "/");
400        req.user_agent = Some("sqlmap/1.5".into());
401        match engine.check(&req) {
402            WafDecision::Block { rule, .. } => assert_eq!(rule, "scanner_detection"),
403            other => panic!("expected Block, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn ip_deny_blocks() {
409        let config = WafConfig {
410            ip_filter: IpFilterConfig {
411                mode: IpFilterMode::Deny,
412                allow_list: vec![],
413                deny_list: vec!["10.0.0.0/8".into()],
414            },
415            ..Default::default()
416        };
417        let engine = WafEngine::new(config).unwrap();
418        let req = make_req("10.1.2.3", "GET", "/");
419        match engine.check(&req) {
420            WafDecision::Block { rule, .. } => assert_eq!(rule, "ip_filter_deny"),
421            other => panic!("expected Block, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn ip_allow_overrides() {
427        let config = WafConfig {
428            ip_filter: IpFilterConfig {
429                mode: IpFilterMode::Deny,
430                allow_list: vec!["10.0.0.1".into()],
431                deny_list: vec!["10.0.0.0/8".into()],
432            },
433            ..Default::default()
434        };
435        let engine = WafEngine::new(config).unwrap();
436        let req = make_req("10.0.0.1", "GET", "/");
437        assert_eq!(engine.check(&req), WafDecision::Allow);
438    }
439
440    #[test]
441    fn ddos_body_limit() {
442        let config = WafConfig {
443            ddos: DdosConfig {
444                max_request_body_size: 50,
445                ..Default::default()
446            },
447            ..Default::default()
448        };
449        let engine = WafEngine::new(config).unwrap();
450        let mut req = make_req("10.0.0.1", "POST", "/upload");
451        req.body = Some("x".repeat(100));
452        match engine.check(&req) {
453            WafDecision::Block { status: 413, .. } => {}
454            other => panic!("expected 413 Block, got {other:?}"),
455        }
456    }
457
458    #[test]
459    fn stats_tracking() {
460        let engine = WafEngine::new(WafConfig::default()).unwrap();
461
462        // Clean request.
463        engine.check(&make_req("10.0.0.1", "GET", "/"));
464        // Attack request.
465        let mut attack = make_req("10.0.0.1", "GET", "/search");
466        attack.query = Some("q=1 UNION SELECT *".into());
467        engine.check(&attack);
468
469        let stats = engine.stats();
470        assert_eq!(stats.total_requests, 2);
471        assert_eq!(stats.allowed, 1);
472        assert_eq!(stats.blocked, 1);
473    }
474
475    #[test]
476    fn audit_log_populated() {
477        let engine = WafEngine::new(WafConfig::default()).unwrap();
478        let mut req = make_req("10.0.0.1", "GET", "/search");
479        req.query = Some("q=1 UNION SELECT *".into());
480        engine.check(&req);
481
482        let events = engine.recent_events(10);
483        assert_eq!(events.len(), 1);
484        assert_eq!(events[0].rule, "sql_injection");
485        assert_eq!(events[0].action, "block");
486    }
487
488    #[test]
489    fn prometheus_metrics_format() {
490        let engine = WafEngine::new(WafConfig::default()).unwrap();
491        engine.check(&make_req("10.0.0.1", "GET", "/"));
492        let metrics = engine.prometheus_metrics();
493        assert!(metrics.contains("waf_requests_total"));
494        assert!(metrics.contains("waf_requests_allowed"));
495    }
496
497    #[test]
498    fn reload_ip_filter() {
499        let engine = WafEngine::new(WafConfig::default()).unwrap();
500        let req = make_req("10.0.0.1", "GET", "/");
501        assert_eq!(engine.check(&req), WafDecision::Allow);
502
503        // Reload to deny 10.0.0.1.
504        engine.reload_ip_filter(IpFilterConfig {
505            mode: IpFilterMode::Deny,
506            allow_list: vec![],
507            deny_list: vec!["10.0.0.1".into()],
508        });
509        assert!(matches!(engine.check(&req), WafDecision::Block { .. }));
510    }
511
512    #[test]
513    fn custom_rule_blocks() {
514        let config = WafConfig {
515            custom_rules: vec![CustomRule {
516                name: "block-admin".into(),
517                match_config: MatchConfig {
518                    path: Some("/admin/**".into()),
519                    ..Default::default()
520                },
521                action: CustomRuleAction::Block,
522                status: 403,
523                reason: Some("Admin access denied".into()),
524            }],
525            ..Default::default()
526        };
527        let engine = WafEngine::new(config).unwrap();
528        let req = make_req("10.0.0.1", "GET", "/admin/settings");
529        match engine.check(&req) {
530            WafDecision::Block { status: 403, .. } => {}
531            other => panic!("expected 403 Block, got {other:?}"),
532        }
533    }
534
535    #[test]
536    fn check_with_headers_returns_headers_on_rate_limit() {
537        let config = WafConfig {
538            rate_limit_rules: vec![RateLimitRule {
539                name: "strict".into(),
540                pattern: "/**".into(),
541                rpm: 1,
542                burst: 0,
543                key_source: rate_limit::KeySource::Ip,
544                delay_mode: rate_limit::DelayMode::NoDelay,
545            }],
546            ..Default::default()
547        };
548        let engine = WafEngine::new(config).unwrap();
549        let req = make_req("10.0.0.1", "GET", "/");
550
551        // First request: allowed.
552        let (d1, h1) = engine.check_with_headers(&req);
553        // The check_with_headers for allow returns empty headers.
554        assert!(matches!(d1, WafDecision::Allow) || h1.is_empty());
555
556        // Second request: rate limited with headers.
557        let (d2, h2) = engine.check_with_headers(&req);
558        if matches!(d2, WafDecision::RateLimit { .. }) {
559            assert!(h2.iter().any(|(k, _)| k == "Retry-After"));
560        }
561    }
562
563    #[test]
564    fn cleanup_does_not_panic() {
565        let engine = WafEngine::new(WafConfig::default()).unwrap();
566        engine.check(&make_req("10.0.0.1", "GET", "/"));
567        engine.cleanup();
568    }
569
570    // Integration test: multiple attack vectors in one request.
571    #[test]
572    fn first_matching_rule_wins() {
573        let config = WafConfig {
574            ip_filter: IpFilterConfig {
575                mode: IpFilterMode::Deny,
576                allow_list: vec![],
577                deny_list: vec!["10.0.0.1".into()],
578            },
579            ..Default::default()
580        };
581        let engine = WafEngine::new(config).unwrap();
582        // Request has both a denied IP AND sqli — IP filter runs first.
583        let mut req = make_req("10.0.0.1", "GET", "/search");
584        req.query = Some("q=UNION SELECT *".into());
585        match engine.check(&req) {
586            WafDecision::Block { rule, .. } => assert_eq!(rule, "ip_filter_deny"),
587            other => panic!("expected ip_filter_deny, got {other:?}"),
588        }
589    }
590}