1use std::net::IpAddr;
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9pub use bytes::Bytes;
10
11pub mod network;
12pub use network::{ClientIpResolver, IpSource, ResolvedClientIp};
13
14pub mod state;
15pub use state::{
16 Acquired, BucketParams, Clock, InMemoryStateStore, ManualClock, RateLimitState, StateStore,
17 SystemClock,
18};
19
20#[cfg(feature = "testkit")]
21pub mod testkit;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Decision {
27 Allow,
28 Block { rule_id: String, reason: String },
29 Monitor { rule_id: String },
30 Score { rule_id: String, points: u32 },
33 Scores(Vec<ScoreItem>),
38 Reject {
43 rule_id: String,
44 reason: String,
45 status: u16,
46 retry_after: Option<u64>,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ScoreItem {
55 pub rule_id: String,
56 pub severity: Severity,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Severity {
69 Critical,
70 Error,
71 Warning,
72 Notice,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum Phase {
77 Connection,
78 RequestLine,
79 Headers,
80 Body,
81 Response,
82}
83
84pub trait WafModule: Send + Sync {
85 fn id(&self) -> &str;
86 fn phase(&self) -> Phase;
87 fn init(&mut self, cfg: &Config);
89 fn inspect(&self, ctx: &RequestContext) -> Decision;
91 fn structural(&self) -> bool {
97 false
98 }
99}
100
101#[derive(Debug, Clone, Deserialize)]
111#[non_exhaustive]
112pub struct Config {
113 pub proxy: ProxyConfig,
114 pub waf: WafConfig,
115 #[serde(default)]
116 pub limits: LimitsConfig,
117 #[serde(default)]
118 pub modules: ModulesConfig,
119 #[serde(default)]
120 pub rate_limit: RateLimitConfig,
121 #[serde(default)]
124 pub network: NetworkConfig,
125 #[serde(default)]
127 pub resilience: ResilienceConfig,
128 #[serde(default)]
132 pub tls: TlsConfig,
133 #[serde(default)]
136 pub metrics: MetricsConfig,
137}
138
139impl Default for Config {
140 fn default() -> Self {
146 Self {
147 proxy: ProxyConfig::default(),
148 waf: WafConfig::default(),
149 limits: LimitsConfig::default(),
150 modules: ModulesConfig::default(),
151 rate_limit: RateLimitConfig::default(),
152 network: NetworkConfig::default(),
153 resilience: ResilienceConfig::default(),
154 tls: TlsConfig::default(),
155 metrics: MetricsConfig::default(),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Deserialize)]
166pub struct MetricsConfig {
167 #[serde(default)]
169 pub enabled: bool,
170 #[serde(default = "default_metrics_listen")]
172 pub listen: std::net::SocketAddr,
173}
174
175fn default_metrics_listen() -> std::net::SocketAddr {
176 "127.0.0.1:9090".parse().expect("valid loopback addr")
177}
178
179impl Default for MetricsConfig {
180 fn default() -> Self {
181 Self { enabled: false, listen: default_metrics_listen() }
182 }
183}
184
185#[derive(Debug, Clone, Deserialize)]
195#[non_exhaustive]
196pub struct TlsConfig {
197 #[serde(default)]
201 pub enabled: bool,
202 #[serde(default)]
204 pub cert_path: String,
205 #[serde(default)]
207 pub key_path: String,
208 #[serde(default = "default_tls_alpn")]
212 pub alpn: Vec<String>,
213}
214
215fn default_tls_alpn() -> Vec<String> {
216 vec!["h2".to_string(), "http/1.1".to_string()]
217}
218
219impl Default for TlsConfig {
220 fn default() -> Self {
221 Self {
222 enabled: false,
223 cert_path: String::new(),
224 key_path: String::new(),
225 alpn: default_tls_alpn(),
226 }
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum FailMode {
238 FailOpen,
240 FailClosed,
242}
243
244#[derive(Debug, Clone, Copy, Deserialize)]
247pub struct ResilienceConfig {
248 #[serde(default = "default_on_upstream_error")]
252 pub on_upstream_error: FailMode,
253 #[serde(default = "default_on_internal_error")]
256 pub on_internal_error: FailMode,
257 #[serde(default = "default_on_config_error")]
260 pub on_config_error: FailMode,
261 #[serde(default = "default_on_parser_limit")]
264 pub on_parser_limit: FailMode,
265 #[serde(default = "default_upstream_timeout_ms")]
268 pub upstream_timeout_ms: u64,
269}
270
271fn default_on_upstream_error() -> FailMode { FailMode::FailClosed }
272fn default_on_internal_error() -> FailMode { FailMode::FailOpen }
273fn default_on_config_error() -> FailMode { FailMode::FailOpen }
274fn default_on_parser_limit() -> FailMode { FailMode::FailClosed }
275fn default_upstream_timeout_ms() -> u64 { 30_000 }
276
277impl Default for ResilienceConfig {
278 fn default() -> Self {
279 Self {
280 on_upstream_error: default_on_upstream_error(),
281 on_internal_error: default_on_internal_error(),
282 on_config_error: default_on_config_error(),
283 on_parser_limit: default_on_parser_limit(),
284 upstream_timeout_ms: default_upstream_timeout_ms(),
285 }
286 }
287}
288
289impl ResilienceConfig {
290 pub fn upstream_timeout(&self) -> std::time::Duration {
292 std::time::Duration::from_millis(self.upstream_timeout_ms)
293 }
294}
295
296#[derive(Debug, Clone, Deserialize)]
302#[non_exhaustive]
303pub struct NetworkConfig {
304 #[serde(default)]
307 pub trusted_proxies: Vec<String>,
308 #[serde(default = "default_client_ip_header")]
310 pub client_ip_header: String,
311 #[serde(default = "default_trusted_hops")]
314 pub trusted_hops: usize,
315}
316
317fn default_client_ip_header() -> String { "x-forwarded-for".to_string() }
318fn default_trusted_hops() -> usize { 1 }
319
320impl Default for NetworkConfig {
321 fn default() -> Self {
322 Self {
323 trusted_proxies: Vec::new(),
324 client_ip_header: default_client_ip_header(),
325 trusted_hops: default_trusted_hops(),
326 }
327 }
328}
329
330pub const MAX_PARANOIA_LEVEL: u8 = 4;
336pub const MAX_TRUSTED_HOPS: usize = 10;
339
340#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum ConfigError {
344 InvalidBackend(String),
345 BlockThresholdZero,
346 ParanoiaOutOfRange(u8),
347 SeverityWeightZero(&'static str),
348 LimitZero(&'static str),
349 RateLimitValueZero(&'static str),
350 TrustedHopsOutOfRange(usize),
351 InvalidCidr(String),
352 EmptyClientIpHeader,
353 ResilienceTimeoutZero,
354 GraphqlCapZero(&'static str),
355 GrpcCapZero(&'static str),
356 TlsPathEmpty(&'static str),
357 TlsAlpnInvalid,
358 CrsNoFiles,
359 WasmNoPlugins,
360 WasmPluginPathEmpty,
361}
362
363impl std::fmt::Display for ConfigError {
364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365 match self {
366 Self::InvalidBackend(b) => write!(
367 f,
368 "proxy.backend must be an absolute http(s) URL with a host, got {b:?}"
369 ),
370 Self::BlockThresholdZero =>
371 write!(f, "waf.block_threshold must be >= 1 (0 would block everything)"),
372 Self::ParanoiaOutOfRange(p) =>
373 write!(f, "waf.paranoia_level must be in 1..={MAX_PARANOIA_LEVEL}, got {p}"),
374 Self::SeverityWeightZero(name) =>
375 write!(f, "waf.severity_scores.{name} must be >= 1 (a 0 weight makes the rule contribute nothing)"),
376 Self::LimitZero(name) =>
377 write!(f, "limits.{name} must be >= 1"),
378 Self::RateLimitValueZero(name) =>
379 write!(f, "rate_limit.{name} must be >= 1 when rate limiting is enabled"),
380 Self::TrustedHopsOutOfRange(h) =>
381 write!(f, "network.trusted_hops must be in 1..={MAX_TRUSTED_HOPS}, got {h}"),
382 Self::InvalidCidr(c) =>
383 write!(f, "network.trusted_proxies contains an invalid CIDR: {c:?}"),
384 Self::EmptyClientIpHeader =>
385 write!(f, "network.client_ip_header must not be empty"),
386 Self::ResilienceTimeoutZero =>
387 write!(f, "resilience.upstream_timeout_ms must be >= 1"),
388 Self::GraphqlCapZero(name) =>
389 write!(f, "modules.graphql.{name} must be >= 1 when the graphql module is enabled"),
390 Self::GrpcCapZero(name) =>
391 write!(f, "modules.grpc.{name} must be >= 1 when the grpc module is enabled"),
392 Self::TlsPathEmpty(name) =>
393 write!(f, "tls.{name} must be set (a PEM file path) when tls is enabled"),
394 Self::TlsAlpnInvalid =>
395 write!(f, "tls.alpn must be a non-empty list of non-empty protocol ids (e.g. \"h2\", \"http/1.1\")"),
396 Self::CrsNoFiles =>
397 write!(f, "modules.crs.files must list at least one file when the crs module is enabled"),
398 Self::WasmNoPlugins =>
399 write!(f, "modules.wasm.plugins must list at least one plugin when the wasm module is enabled"),
400 Self::WasmPluginPathEmpty =>
401 write!(f, "modules.wasm.plugins[].path must be a non-empty .wasm file path"),
402 }
403 }
404}
405
406impl std::error::Error for ConfigError {}
407
408impl Config {
409 pub fn validate(&self) -> Result<(), ConfigError> {
413 let backend = self.proxy.backend.trim();
415 let authority = backend
416 .strip_prefix("http://")
417 .or_else(|| backend.strip_prefix("https://"));
418 match authority {
419 Some(a) if !a.is_empty() && !a.starts_with('/') => {}
420 _ => return Err(ConfigError::InvalidBackend(self.proxy.backend.clone())),
421 }
422
423 if self.waf.block_threshold == 0 {
425 return Err(ConfigError::BlockThresholdZero);
426 }
427 if !(1..=MAX_PARANOIA_LEVEL).contains(&self.waf.paranoia_level) {
428 return Err(ConfigError::ParanoiaOutOfRange(self.waf.paranoia_level));
429 }
430 let s = &self.waf.severity_scores;
431 for (name, v) in [
432 ("critical", s.critical),
433 ("error", s.error),
434 ("warning", s.warning),
435 ("notice", s.notice),
436 ] {
437 if v == 0 {
438 return Err(ConfigError::SeverityWeightZero(name));
439 }
440 }
441
442 let l = &self.limits;
444 for (name, v) in [
445 ("max_body_size", l.max_body_size),
446 ("max_header_size", l.max_header_size),
447 ("max_headers", l.max_headers),
448 ("max_params", l.max_params),
449 ("max_cookies", l.max_cookies),
450 ("max_json_depth", l.max_json_depth),
451 ] {
452 if v == 0 {
453 return Err(ConfigError::LimitZero(name));
454 }
455 }
456
457 if self.rate_limit.enabled {
459 let r = &self.rate_limit;
460 if r.requests == 0 {
461 return Err(ConfigError::RateLimitValueZero("requests"));
462 }
463 if r.window_seconds == 0 {
464 return Err(ConfigError::RateLimitValueZero("window_seconds"));
465 }
466 if matches!(r.burst, Some(0)) {
467 return Err(ConfigError::RateLimitValueZero("burst"));
468 }
469 if r.max_tracked_keys == 0 {
470 return Err(ConfigError::RateLimitValueZero("max_tracked_keys"));
471 }
472 if r.action == RateLimitAction::Score && r.score == 0 {
473 return Err(ConfigError::RateLimitValueZero("score"));
474 }
475 }
476
477 if !(1..=MAX_TRUSTED_HOPS).contains(&self.network.trusted_hops) {
479 return Err(ConfigError::TrustedHopsOutOfRange(self.network.trusted_hops));
480 }
481 for cidr in &self.network.trusted_proxies {
482 if !network::is_valid_cidr(cidr) {
483 return Err(ConfigError::InvalidCidr(cidr.clone()));
484 }
485 }
486 if self.network.client_ip_header.trim().is_empty() {
487 return Err(ConfigError::EmptyClientIpHeader);
488 }
489
490 if self.resilience.upstream_timeout_ms == 0 {
492 return Err(ConfigError::ResilienceTimeoutZero);
493 }
494
495 if self.tls.enabled {
499 if self.tls.cert_path.trim().is_empty() {
500 return Err(ConfigError::TlsPathEmpty("cert_path"));
501 }
502 if self.tls.key_path.trim().is_empty() {
503 return Err(ConfigError::TlsPathEmpty("key_path"));
504 }
505 if self.tls.alpn.is_empty() || self.tls.alpn.iter().any(|p| p.trim().is_empty()) {
506 return Err(ConfigError::TlsAlpnInvalid);
507 }
508 }
509
510 if self.modules.graphql.enabled {
512 let g = &self.modules.graphql;
513 for (name, v) in [
514 ("max_depth", g.max_depth),
515 ("max_aliases", g.max_aliases),
516 ("max_fields", g.max_fields),
517 ("max_directives", g.max_directives),
518 ("max_batch", g.max_batch),
519 ] {
520 if v == 0 {
521 return Err(ConfigError::GraphqlCapZero(name));
522 }
523 }
524 }
525
526 if self.modules.grpc.enabled {
528 let g = &self.modules.grpc;
529 for (name, zero) in [
530 ("max_message_bytes", g.max_message_bytes == 0),
531 ("max_fields", g.max_fields == 0),
532 ("max_depth", g.max_depth == 0),
533 ] {
534 if zero {
535 return Err(ConfigError::GrpcCapZero(name));
536 }
537 }
538 }
539
540 if self.modules.crs.enabled && self.modules.crs.files.is_empty() {
543 return Err(ConfigError::CrsNoFiles);
544 }
545
546 if self.modules.wasm.enabled {
549 if self.modules.wasm.plugins.is_empty() {
550 return Err(ConfigError::WasmNoPlugins);
551 }
552 if self.modules.wasm.plugins.iter().any(|p| p.path.trim().is_empty()) {
553 return Err(ConfigError::WasmPluginPathEmpty);
554 }
555 }
556
557 Ok(())
558 }
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
566#[serde(rename_all = "snake_case")]
567pub enum RateLimitKey {
568 #[default]
569 ClientIp,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
574#[serde(rename_all = "snake_case")]
575pub enum RateLimitAction {
576 #[default]
578 Block,
579 Score,
581}
582
583#[derive(Debug, Clone, Deserialize)]
584pub struct RateLimitConfig {
585 #[serde(default)]
587 pub enabled: bool,
588 #[serde(default)]
589 pub key: RateLimitKey,
590 #[serde(default = "default_rl_requests")]
592 pub requests: u32,
593 #[serde(default = "default_rl_window")]
594 pub window_seconds: u64,
595 #[serde(default)]
597 pub burst: Option<u32>,
598 #[serde(default)]
599 pub action: RateLimitAction,
600 #[serde(default = "default_rl_score")]
602 pub score: u32,
603 #[serde(default = "default_rl_max_keys")]
606 pub max_tracked_keys: usize,
607}
608
609fn default_rl_requests() -> u32 { 100 }
610fn default_rl_window() -> u64 { 60 }
611fn default_rl_score() -> u32 { 5 }
612fn default_rl_max_keys() -> usize { 100_000 }
613
614impl Default for RateLimitConfig {
615 fn default() -> Self {
616 Self {
617 enabled: false,
618 key: RateLimitKey::ClientIp,
619 requests: default_rl_requests(),
620 window_seconds: default_rl_window(),
621 burst: None,
622 action: RateLimitAction::Block,
623 score: default_rl_score(),
624 max_tracked_keys: default_rl_max_keys(),
625 }
626 }
627}
628
629#[derive(Debug, Clone, Deserialize, Default)]
630pub struct ModulesConfig {
631 #[serde(default)]
632 pub sqli: ModuleConfig,
633 #[serde(default)]
634 pub xss: ModuleConfig,
635 #[serde(default)]
636 pub path_traversal: ModuleConfig,
637 #[serde(default)]
638 pub rce: ModuleConfig,
639 #[serde(default)]
640 pub lfi_rfi: ModuleConfig,
641 #[serde(default)]
642 pub ssrf: ModuleConfig,
643 #[serde(default)]
644 pub ldap: ModuleConfig,
645 #[serde(default)]
646 pub nosql: ModuleConfig,
647 #[serde(default)]
648 pub mail: ModuleConfig,
649 #[serde(default)]
650 pub ssti: ModuleConfig,
651 #[serde(default)]
652 pub scanner: ModuleConfig,
653 #[serde(default)]
654 pub ssi: ModuleConfig,
655 #[serde(default)]
656 pub xxe: ModuleConfig,
657 #[serde(default)]
658 pub header_injection: ModuleConfig,
659 #[serde(default)]
664 pub evasion: ModuleConfig,
665 #[serde(default)]
668 pub request_smuggling: ModuleConfig,
669 #[serde(default)]
673 pub graphql: GraphqlConfig,
674 #[serde(default)]
679 pub grpc: GrpcConfig,
680 #[serde(default)]
683 pub crs: CrsConfig,
684 #[serde(default)]
687 pub wasm: WasmConfig,
688}
689
690#[derive(Debug, Clone, Default, Deserialize)]
695pub struct CrsConfig {
696 #[serde(default)]
698 pub enabled: bool,
699 #[serde(default)]
702 pub files: Vec<String>,
703}
704
705#[derive(Debug, Clone, Deserialize)]
710pub struct WasmConfig {
711 #[serde(default)]
713 pub enabled: bool,
714 #[serde(default = "default_wasm_pool_size")]
717 pub pool_size: usize,
718 #[serde(default = "default_wasm_fuel")]
721 pub fuel_per_request: u64,
722 #[serde(default = "default_wasm_max_memory")]
724 pub max_memory_bytes: usize,
725 #[serde(default = "default_wasm_checkout_timeout_ms")]
727 pub checkout_timeout_ms: u64,
728 #[serde(default)]
730 pub plugins: Vec<WasmPluginConfig>,
731}
732
733fn default_wasm_pool_size() -> usize { 4 }
734fn default_wasm_fuel() -> u64 { 10_000_000 }
735fn default_wasm_max_memory() -> usize { 16 * 1024 * 1024 }
736fn default_wasm_checkout_timeout_ms() -> u64 { 100 }
737
738impl Default for WasmConfig {
739 fn default() -> Self {
740 WasmConfig {
741 enabled: false,
742 pool_size: default_wasm_pool_size(),
743 fuel_per_request: default_wasm_fuel(),
744 max_memory_bytes: default_wasm_max_memory(),
745 checkout_timeout_ms: default_wasm_checkout_timeout_ms(),
746 plugins: Vec::new(),
747 }
748 }
749}
750
751#[derive(Debug, Clone, Default, Deserialize)]
754pub struct WasmPluginConfig {
755 pub path: String,
756 #[serde(default)]
757 pub config: Option<String>,
758}
759
760#[derive(Debug, Clone, Deserialize)]
761pub struct ModuleConfig {
762 #[serde(default = "default_true")]
763 pub enabled: bool,
764}
765
766fn default_true() -> bool { true }
767
768impl Default for ModuleConfig {
769 fn default() -> Self {
770 Self { enabled: true }
771 }
772}
773
774#[derive(Debug, Clone, Deserialize)]
778pub struct GraphqlConfig {
779 #[serde(default)]
781 pub enabled: bool,
782 #[serde(default = "default_graphql_paths")]
787 pub paths: Vec<String>,
788 #[serde(default = "default_graphql_max_depth")]
790 pub max_depth: u32,
791 #[serde(default = "default_graphql_max_aliases")]
793 pub max_aliases: u32,
794 #[serde(default = "default_graphql_max_fields")]
796 pub max_fields: u32,
797 #[serde(default = "default_graphql_max_directives")]
799 pub max_directives: u32,
800 #[serde(default = "default_graphql_max_batch")]
802 pub max_batch: u32,
803 #[serde(default)]
805 pub block_introspection: bool,
806}
807
808fn default_graphql_paths() -> Vec<String> { vec!["/graphql".to_string()] }
809fn default_graphql_max_depth() -> u32 { 15 }
810fn default_graphql_max_aliases() -> u32 { 30 }
811fn default_graphql_max_fields() -> u32 { 1000 }
812fn default_graphql_max_directives() -> u32 { 50 }
813fn default_graphql_max_batch() -> u32 { 10 }
814
815impl Default for GraphqlConfig {
816 fn default() -> Self {
817 Self {
818 enabled: false,
819 paths: default_graphql_paths(),
820 max_depth: default_graphql_max_depth(),
821 max_aliases: default_graphql_max_aliases(),
822 max_fields: default_graphql_max_fields(),
823 max_directives: default_graphql_max_directives(),
824 max_batch: default_graphql_max_batch(),
825 block_introspection: false,
826 }
827 }
828}
829
830#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
836#[serde(rename_all = "snake_case")]
837pub enum CompressedPolicy {
838 Reject,
839 Passthrough,
840}
841
842#[derive(Debug, Clone, Deserialize)]
848pub struct GrpcConfig {
849 #[serde(default)]
851 pub enabled: bool,
852 #[serde(default = "default_grpc_max_message_bytes")]
854 pub max_message_bytes: u64,
855 #[serde(default = "default_grpc_max_fields")]
857 pub max_fields: u32,
858 #[serde(default = "default_grpc_max_depth")]
860 pub max_depth: u32,
861 #[serde(default = "default_grpc_on_compressed")]
863 pub on_compressed: CompressedPolicy,
864}
865
866fn default_grpc_max_message_bytes() -> u64 { 4 * 1024 * 1024 }
867fn default_grpc_max_fields() -> u32 { 4096 }
868fn default_grpc_max_depth() -> u32 { 16 }
869fn default_grpc_on_compressed() -> CompressedPolicy { CompressedPolicy::Reject }
870
871impl Default for GrpcConfig {
872 fn default() -> Self {
873 Self {
874 enabled: false,
875 max_message_bytes: default_grpc_max_message_bytes(),
876 max_fields: default_grpc_max_fields(),
877 max_depth: default_grpc_max_depth(),
878 on_compressed: default_grpc_on_compressed(),
879 }
880 }
881}
882
883#[derive(Debug, Clone, Copy, Deserialize)]
886pub struct SeverityScores {
887 #[serde(default = "default_critical_score")]
888 pub critical: u32,
889 #[serde(default = "default_error_score")]
890 pub error: u32,
891 #[serde(default = "default_warning_score")]
892 pub warning: u32,
893 #[serde(default = "default_notice_score")]
894 pub notice: u32,
895}
896
897fn default_critical_score() -> u32 { 6 }
902fn default_error_score() -> u32 { 4 }
903fn default_warning_score() -> u32 { 3 }
904fn default_notice_score() -> u32 { 2 }
905
906impl Default for SeverityScores {
907 fn default() -> Self {
908 Self {
909 critical: default_critical_score(),
910 error: default_error_score(),
911 warning: default_warning_score(),
912 notice: default_notice_score(),
913 }
914 }
915}
916
917impl SeverityScores {
918 pub fn points_for(&self, severity: Severity) -> u32 {
920 match severity {
921 Severity::Critical => self.critical,
922 Severity::Error => self.error,
923 Severity::Warning => self.warning,
924 Severity::Notice => self.notice,
925 }
926 }
927}
928
929#[derive(Debug, Clone, Deserialize)]
930pub struct ProxyConfig {
931 pub listen: std::net::SocketAddr,
932 pub backend: String,
933}
934
935#[derive(Debug, Clone, Deserialize)]
936pub struct WafConfig {
937 pub mode: WafMode,
938 #[serde(default = "default_block_threshold")]
942 pub block_threshold: u32,
943 #[serde(default = "default_paranoia_level")]
945 pub paranoia_level: u8,
946 #[serde(default)]
948 pub severity_scores: SeverityScores,
949}
950
951fn default_block_threshold() -> u32 {
952 5
953}
954
955fn default_paranoia_level() -> u8 {
956 1
957}
958
959impl Default for ProxyConfig {
960 fn default() -> Self {
963 Self {
964 listen: "127.0.0.1:8080".parse().expect("valid loopback addr"),
965 backend: "http://localhost:8080".to_string(),
966 }
967 }
968}
969
970impl Default for WafConfig {
971 fn default() -> Self {
974 Self {
975 mode: WafMode::DetectionOnly,
976 block_threshold: default_block_threshold(),
977 paranoia_level: default_paranoia_level(),
978 severity_scores: SeverityScores::default(),
979 }
980 }
981}
982
983#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
984#[serde(rename_all = "kebab-case")]
985pub enum WafMode {
986 DetectionOnly,
987 Blocking,
988}
989
990#[derive(Debug, Clone, Deserialize)]
992#[non_exhaustive]
993pub struct LimitsConfig {
994 #[serde(default = "default_max_body_size")]
995 pub max_body_size: usize,
996 #[serde(default = "default_max_header_size")]
997 pub max_header_size: usize,
998 #[serde(default = "default_max_headers")]
999 pub max_headers: usize,
1000 #[serde(default = "default_max_params")]
1001 pub max_params: usize,
1002 #[serde(default = "default_max_cookies")]
1003 pub max_cookies: usize,
1004 #[serde(default = "default_max_json_depth")]
1005 pub max_json_depth: usize,
1006}
1007
1008fn default_max_body_size() -> usize { 1_048_576 } fn default_max_header_size() -> usize { 8_192 } fn default_max_headers() -> usize { 100 }
1011fn default_max_params() -> usize { 100 }
1012fn default_max_cookies() -> usize { 50 }
1013fn default_max_json_depth() -> usize { 20 }
1014
1015impl Default for LimitsConfig {
1016 fn default() -> Self {
1017 Self {
1018 max_body_size: default_max_body_size(),
1019 max_header_size: default_max_header_size(),
1020 max_headers: default_max_headers(),
1021 max_params: default_max_params(),
1022 max_cookies: default_max_cookies(),
1023 max_json_depth: default_max_json_depth(),
1024 }
1025 }
1026}
1027
1028#[derive(Debug, Clone, Default)]
1032pub enum ParsedBody {
1033 #[default]
1034 None,
1035 FormUrlEncoded(Vec<(String, String)>),
1036 Multipart(Vec<MultipartField>),
1037 JsonFlattened(Vec<(String, String)>),
1039 Raw(Bytes),
1040}
1041
1042#[derive(Debug, Clone)]
1043pub struct MultipartField {
1044 pub name: String,
1045 pub filename: Option<String>,
1046 pub content_type: Option<String>,
1047 pub data: Bytes,
1048}
1049
1050#[derive(Debug, Clone, Default)]
1054pub struct Normalized {
1055 pub path: String,
1057 pub query: Option<String>,
1059 pub query_params: Vec<(String, String)>,
1061 pub cookies: Vec<(String, String)>,
1063 pub headers: Vec<(String, String)>,
1065 pub body: ParsedBody,
1066 pub double_encoding_detected: bool,
1068 pub null_byte_detected: bool,
1075 pub derived_decoded: Vec<String>,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093pub struct ScoreContribution {
1094 pub module: String,
1096 pub rule_id: String,
1098 pub severity: Option<Severity>,
1101 pub points: u32,
1103}
1104
1105#[cfg(test)]
1106mod scoring_serde_tests {
1107 use super::*;
1108
1109 #[test]
1113 fn score_contribution_json_shape_is_stable() {
1114 let items = vec![
1115 ScoreContribution {
1116 module: "sqli".to_string(),
1117 rule_id: "sqli-union-select".to_string(),
1118 severity: Some(Severity::Critical),
1119 points: 5,
1120 },
1121 ScoreContribution {
1122 module: "rate_limit".to_string(),
1123 rule_id: "rl-client-ip".to_string(),
1124 severity: None,
1125 points: 3,
1126 },
1127 ];
1128 let json = serde_json::to_string(&items).unwrap();
1129 assert_eq!(
1130 json,
1131 r#"[{"module":"sqli","rule_id":"sqli-union-select","severity":"critical","points":5},{"module":"rate_limit","rule_id":"rl-client-ip","severity":null,"points":3}]"#
1132 );
1133 let back: Vec<ScoreContribution> = serde_json::from_str(&json).unwrap();
1135 assert_eq!(back, items);
1136 }
1137}
1138
1139#[derive(Debug)]
1142pub struct RequestContext {
1143 pub client_ip: IpAddr,
1145 pub request_id: String,
1146 pub timestamp: SystemTime,
1147 pub method: String,
1149 pub path: String,
1150 pub raw_path: String,
1151 pub query: Option<String>,
1152 pub http_version: String,
1153 pub headers: Vec<(String, String)>,
1155 pub cookies: Vec<(String, String)>,
1156 pub body: Bytes,
1158 pub normalized: Normalized,
1160 pub score: u32,
1162 pub score_contributions: Vec<ScoreContribution>,
1164}
1165
1166#[cfg(test)]
1169mod config_validation_tests {
1170 use super::*;
1171
1172 fn valid() -> Config {
1173 Config {
1174 proxy: ProxyConfig {
1175 listen: "127.0.0.1:8080".parse().unwrap(),
1176 backend: "http://localhost:3000".to_string(),
1177 },
1178 waf: WafConfig {
1179 mode: WafMode::Blocking,
1180 block_threshold: 5,
1181 paranoia_level: 2,
1182 severity_scores: SeverityScores::default(),
1183 },
1184 limits: LimitsConfig::default(),
1185 modules: ModulesConfig::default(),
1186 rate_limit: RateLimitConfig::default(),
1187 network: NetworkConfig::default(),
1188 resilience: ResilienceConfig::default(),
1189 tls: TlsConfig::default(),
1190 metrics: MetricsConfig::default(),
1191 }
1192 }
1193
1194 #[test]
1195 fn valid_config_passes() {
1196 assert!(valid().validate().is_ok());
1197 }
1198
1199 #[test]
1200 fn paranoia_4_is_legal_forward_compatible() {
1201 let mut c = valid();
1202 c.waf.paranoia_level = MAX_PARANOIA_LEVEL; assert!(c.validate().is_ok());
1204 }
1205
1206 #[test]
1207 fn paranoia_out_of_range_rejected() {
1208 let mut c = valid();
1209 c.waf.paranoia_level = 0;
1210 assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(0)));
1211 c.waf.paranoia_level = 5;
1212 assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(5)));
1213 }
1214
1215 #[test]
1216 fn block_threshold_zero_rejected() {
1217 let mut c = valid();
1218 c.waf.block_threshold = 0;
1219 assert_eq!(c.validate(), Err(ConfigError::BlockThresholdZero));
1220 }
1221
1222 #[test]
1223 fn grpc_disabled_ignores_caps() {
1224 assert!(valid().validate().is_ok());
1226 }
1227
1228 #[test]
1229 fn grpc_enabled_rejects_zero_caps() {
1230 let mut c = valid();
1231 c.modules.grpc.enabled = true;
1232 assert!(c.validate().is_ok()); c.modules.grpc.max_depth = 0;
1234 assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_depth")));
1235 c.modules.grpc.max_depth = 16;
1236 c.modules.grpc.max_message_bytes = 0;
1237 assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_message_bytes")));
1238 }
1239
1240 #[test]
1241 fn tls_disabled_ignores_empty_paths() {
1242 assert!(valid().validate().is_ok());
1244 }
1245
1246 #[test]
1247 fn tls_enabled_requires_cert_and_key_paths() {
1248 let mut c = valid();
1249 c.tls.enabled = true;
1250 assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("cert_path")));
1251 c.tls.cert_path = "cert.pem".to_string();
1252 assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("key_path")));
1253 c.tls.key_path = "key.pem".to_string();
1254 assert!(c.validate().is_ok());
1255 }
1256
1257 #[test]
1258 fn tls_enabled_rejects_empty_alpn() {
1259 let mut c = valid();
1260 c.tls.enabled = true;
1261 c.tls.cert_path = "cert.pem".to_string();
1262 c.tls.key_path = "key.pem".to_string();
1263 c.tls.alpn = vec![];
1264 assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1265 c.tls.alpn = vec!["h2".to_string(), " ".to_string()];
1266 assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1267 }
1268
1269 #[test]
1270 fn crs_enabled_requires_files() {
1271 let mut c = valid();
1272 c.modules.crs.enabled = true;
1273 assert_eq!(c.validate(), Err(ConfigError::CrsNoFiles));
1274 c.modules.crs.files = vec!["rules.conf".to_string()];
1275 assert!(c.validate().is_ok());
1276 }
1277
1278 #[test]
1279 fn crs_disabled_ignores_empty_files() {
1280 assert!(valid().validate().is_ok());
1282 }
1283
1284 #[test]
1285 fn wasm_enabled_requires_plugins_with_paths() {
1286 let mut c = valid();
1287 c.modules.wasm.enabled = true;
1288 assert_eq!(c.validate(), Err(ConfigError::WasmNoPlugins));
1289 c.modules.wasm.plugins = vec![WasmPluginConfig { path: " ".into(), config: None }];
1290 assert_eq!(c.validate(), Err(ConfigError::WasmPluginPathEmpty));
1291 c.modules.wasm.plugins = vec![WasmPluginConfig { path: "f.wasm".into(), config: None }];
1292 assert!(c.validate().is_ok());
1293 }
1294
1295 #[test]
1296 fn wasm_disabled_ignores_empty_plugins() {
1297 assert!(valid().validate().is_ok());
1299 }
1300
1301 #[test]
1302 fn zero_severity_weight_rejected() {
1303 let mut c = valid();
1304 c.waf.severity_scores.warning = 0;
1305 assert_eq!(c.validate(), Err(ConfigError::SeverityWeightZero("warning")));
1306 }
1307
1308 #[test]
1309 fn zero_limit_rejected() {
1310 let mut c = valid();
1311 c.limits.max_json_depth = 0;
1312 assert_eq!(c.validate(), Err(ConfigError::LimitZero("max_json_depth")));
1313 }
1314
1315 #[test]
1316 fn invalid_backend_rejected() {
1317 let mut c = valid();
1318 c.proxy.backend = "localhost:3000".to_string(); assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1320 c.proxy.backend = "http://".to_string(); assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1322 }
1323
1324 #[test]
1325 fn rate_limit_values_checked_only_when_enabled() {
1326 let mut c = valid();
1327 c.rate_limit.enabled = false;
1329 c.rate_limit.requests = 0;
1330 assert!(c.validate().is_ok());
1331 c.rate_limit.enabled = true;
1333 assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("requests")));
1334 }
1335
1336 #[test]
1337 fn rate_limit_score_action_requires_positive_score() {
1338 let mut c = valid();
1339 c.rate_limit.enabled = true;
1340 c.rate_limit.action = RateLimitAction::Score;
1341 c.rate_limit.score = 0;
1342 assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("score")));
1343 }
1344
1345 #[test]
1346 fn trusted_hops_out_of_range_rejected() {
1347 let mut c = valid();
1348 c.network.trusted_hops = 0;
1349 assert_eq!(c.validate(), Err(ConfigError::TrustedHopsOutOfRange(0)));
1350 c.network.trusted_hops = MAX_TRUSTED_HOPS + 1;
1351 assert_eq!(
1352 c.validate(),
1353 Err(ConfigError::TrustedHopsOutOfRange(MAX_TRUSTED_HOPS + 1))
1354 );
1355 }
1356
1357 #[test]
1358 fn invalid_cidr_in_trusted_proxies_rejected() {
1359 let mut c = valid();
1360 c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "999.0.0.0/8".to_string()];
1361 assert_eq!(
1362 c.validate(),
1363 Err(ConfigError::InvalidCidr("999.0.0.0/8".to_string()))
1364 );
1365 }
1366
1367 #[test]
1368 fn valid_cidrs_pass() {
1369 let mut c = valid();
1370 c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "::1".to_string()];
1371 assert!(c.validate().is_ok());
1372 }
1373
1374 #[test]
1375 fn empty_client_ip_header_rejected() {
1376 let mut c = valid();
1377 c.network.client_ip_header = " ".to_string();
1378 assert_eq!(c.validate(), Err(ConfigError::EmptyClientIpHeader));
1379 }
1380
1381 #[test]
1382 fn zero_upstream_timeout_rejected() {
1383 let mut c = valid();
1384 c.resilience.upstream_timeout_ms = 0;
1385 assert_eq!(c.validate(), Err(ConfigError::ResilienceTimeoutZero));
1386 }
1387
1388 #[test]
1389 fn resilience_defaults_match_documented_posture() {
1390 let r = ResilienceConfig::default();
1391 assert_eq!(r.on_internal_error, FailMode::FailOpen);
1392 assert_eq!(r.on_upstream_error, FailMode::FailClosed);
1393 assert_eq!(r.on_config_error, FailMode::FailOpen);
1394 assert_eq!(r.on_parser_limit, FailMode::FailClosed);
1395 }
1396}