1pub 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
22pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum WafDecision {
36 Allow,
38 Block {
40 status: u16,
41 reason: String,
42 rule: String,
43 },
44 RateLimit { retry_after: u64 },
46 Challenge { html: String },
48}
49
50#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct WafConfig {
76 #[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 #[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
118pub 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 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 pub fn check(&self, req: &WafRequest) -> WafDecision {
155 self.check_impl(req, false).0
156 }
157
158 pub fn check_with_headers(&self, req: &WafRequest) -> (WafDecision, Vec<(String, String)>) {
160 self.check_impl(req, true)
161 }
162
163 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 if let Some(decision) = self.ip_filter.check(req.client_ip) {
180 self.record_decision(req, &decision);
181 return (decision, vec![]);
182 }
183
184 if let Some(decision) = self.geo_blocker.check_request(req) {
186 self.record_decision(req, &decision);
187 return (decision, vec![]);
188 }
189
190 if let Some(decision) = self.ddos_guard.check(req) {
192 self.record_decision(req, &decision);
193 return (decision, vec![]);
194 }
195
196 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 if let Some(decision) = self.bot_detector.check(req) {
205 self.record_decision(req, &decision);
206 return (decision, vec![]);
207 }
208
209 if let Some(decision) = self.rule_engine.inspect(req) {
211 self.record_decision(req, &decision);
212 return (decision, vec![]);
213 }
214
215 {
217 let mut s = self.stats.lock();
218 s.allowed += 1;
219 }
220 (WafDecision::Allow, vec![])
221 }
222
223 pub fn record_connection(&self, ip: IpAddr) {
225 self.ddos_guard.record_connection(ip);
226 }
227
228 pub fn release_connection(&self, ip: IpAddr) {
230 self.ddos_guard.release_connection(ip);
231 }
232
233 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 pub fn audit_log(&self) -> &WafAuditLog {
242 &self.audit_log
243 }
244
245 pub fn recent_events(&self, count: usize) -> Vec<WafEvent> {
247 self.audit_log.recent(count)
248 }
249
250 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 pub fn reload_ip_filter(&self, config: IpFilterConfig) {
268 self.ip_filter.reload(config);
269 }
270
271 pub fn cleanup(&self) {
273 self.ddos_guard.cleanup(300); self.rate_limiter
275 .cleanup(std::time::Duration::from_secs(600)); }
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 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 engine.check(&make_req("10.0.0.1", "GET", "/"));
464 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 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 let (d1, h1) = engine.check_with_headers(&req);
553 assert!(matches!(d1, WafDecision::Allow) || h1.is_empty());
555
556 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 #[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 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}