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)]
662 pub request_smuggling: ModuleConfig,
663 #[serde(default)]
667 pub graphql: GraphqlConfig,
668 #[serde(default)]
673 pub grpc: GrpcConfig,
674 #[serde(default)]
677 pub crs: CrsConfig,
678 #[serde(default)]
681 pub wasm: WasmConfig,
682}
683
684#[derive(Debug, Clone, Default, Deserialize)]
689pub struct CrsConfig {
690 #[serde(default)]
692 pub enabled: bool,
693 #[serde(default)]
696 pub files: Vec<String>,
697}
698
699#[derive(Debug, Clone, Deserialize)]
704pub struct WasmConfig {
705 #[serde(default)]
707 pub enabled: bool,
708 #[serde(default = "default_wasm_pool_size")]
711 pub pool_size: usize,
712 #[serde(default = "default_wasm_fuel")]
715 pub fuel_per_request: u64,
716 #[serde(default = "default_wasm_max_memory")]
718 pub max_memory_bytes: usize,
719 #[serde(default = "default_wasm_checkout_timeout_ms")]
721 pub checkout_timeout_ms: u64,
722 #[serde(default)]
724 pub plugins: Vec<WasmPluginConfig>,
725}
726
727fn default_wasm_pool_size() -> usize { 4 }
728fn default_wasm_fuel() -> u64 { 10_000_000 }
729fn default_wasm_max_memory() -> usize { 16 * 1024 * 1024 }
730fn default_wasm_checkout_timeout_ms() -> u64 { 100 }
731
732impl Default for WasmConfig {
733 fn default() -> Self {
734 WasmConfig {
735 enabled: false,
736 pool_size: default_wasm_pool_size(),
737 fuel_per_request: default_wasm_fuel(),
738 max_memory_bytes: default_wasm_max_memory(),
739 checkout_timeout_ms: default_wasm_checkout_timeout_ms(),
740 plugins: Vec::new(),
741 }
742 }
743}
744
745#[derive(Debug, Clone, Default, Deserialize)]
748pub struct WasmPluginConfig {
749 pub path: String,
750 #[serde(default)]
751 pub config: Option<String>,
752}
753
754#[derive(Debug, Clone, Deserialize)]
755pub struct ModuleConfig {
756 #[serde(default = "default_true")]
757 pub enabled: bool,
758}
759
760fn default_true() -> bool { true }
761
762impl Default for ModuleConfig {
763 fn default() -> Self {
764 Self { enabled: true }
765 }
766}
767
768#[derive(Debug, Clone, Deserialize)]
772pub struct GraphqlConfig {
773 #[serde(default)]
775 pub enabled: bool,
776 #[serde(default = "default_graphql_paths")]
781 pub paths: Vec<String>,
782 #[serde(default = "default_graphql_max_depth")]
784 pub max_depth: u32,
785 #[serde(default = "default_graphql_max_aliases")]
787 pub max_aliases: u32,
788 #[serde(default = "default_graphql_max_fields")]
790 pub max_fields: u32,
791 #[serde(default = "default_graphql_max_directives")]
793 pub max_directives: u32,
794 #[serde(default = "default_graphql_max_batch")]
796 pub max_batch: u32,
797 #[serde(default)]
799 pub block_introspection: bool,
800}
801
802fn default_graphql_paths() -> Vec<String> { vec!["/graphql".to_string()] }
803fn default_graphql_max_depth() -> u32 { 15 }
804fn default_graphql_max_aliases() -> u32 { 30 }
805fn default_graphql_max_fields() -> u32 { 1000 }
806fn default_graphql_max_directives() -> u32 { 50 }
807fn default_graphql_max_batch() -> u32 { 10 }
808
809impl Default for GraphqlConfig {
810 fn default() -> Self {
811 Self {
812 enabled: false,
813 paths: default_graphql_paths(),
814 max_depth: default_graphql_max_depth(),
815 max_aliases: default_graphql_max_aliases(),
816 max_fields: default_graphql_max_fields(),
817 max_directives: default_graphql_max_directives(),
818 max_batch: default_graphql_max_batch(),
819 block_introspection: false,
820 }
821 }
822}
823
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
830#[serde(rename_all = "snake_case")]
831pub enum CompressedPolicy {
832 Reject,
833 Passthrough,
834}
835
836#[derive(Debug, Clone, Deserialize)]
842pub struct GrpcConfig {
843 #[serde(default)]
845 pub enabled: bool,
846 #[serde(default = "default_grpc_max_message_bytes")]
848 pub max_message_bytes: u64,
849 #[serde(default = "default_grpc_max_fields")]
851 pub max_fields: u32,
852 #[serde(default = "default_grpc_max_depth")]
854 pub max_depth: u32,
855 #[serde(default = "default_grpc_on_compressed")]
857 pub on_compressed: CompressedPolicy,
858}
859
860fn default_grpc_max_message_bytes() -> u64 { 4 * 1024 * 1024 }
861fn default_grpc_max_fields() -> u32 { 4096 }
862fn default_grpc_max_depth() -> u32 { 16 }
863fn default_grpc_on_compressed() -> CompressedPolicy { CompressedPolicy::Reject }
864
865impl Default for GrpcConfig {
866 fn default() -> Self {
867 Self {
868 enabled: false,
869 max_message_bytes: default_grpc_max_message_bytes(),
870 max_fields: default_grpc_max_fields(),
871 max_depth: default_grpc_max_depth(),
872 on_compressed: default_grpc_on_compressed(),
873 }
874 }
875}
876
877#[derive(Debug, Clone, Copy, Deserialize)]
880pub struct SeverityScores {
881 #[serde(default = "default_critical_score")]
882 pub critical: u32,
883 #[serde(default = "default_error_score")]
884 pub error: u32,
885 #[serde(default = "default_warning_score")]
886 pub warning: u32,
887 #[serde(default = "default_notice_score")]
888 pub notice: u32,
889}
890
891fn default_critical_score() -> u32 { 6 }
896fn default_error_score() -> u32 { 4 }
897fn default_warning_score() -> u32 { 3 }
898fn default_notice_score() -> u32 { 2 }
899
900impl Default for SeverityScores {
901 fn default() -> Self {
902 Self {
903 critical: default_critical_score(),
904 error: default_error_score(),
905 warning: default_warning_score(),
906 notice: default_notice_score(),
907 }
908 }
909}
910
911impl SeverityScores {
912 pub fn points_for(&self, severity: Severity) -> u32 {
914 match severity {
915 Severity::Critical => self.critical,
916 Severity::Error => self.error,
917 Severity::Warning => self.warning,
918 Severity::Notice => self.notice,
919 }
920 }
921}
922
923#[derive(Debug, Clone, Deserialize)]
924pub struct ProxyConfig {
925 pub listen: std::net::SocketAddr,
926 pub backend: String,
927}
928
929#[derive(Debug, Clone, Deserialize)]
930pub struct WafConfig {
931 pub mode: WafMode,
932 #[serde(default = "default_block_threshold")]
936 pub block_threshold: u32,
937 #[serde(default = "default_paranoia_level")]
939 pub paranoia_level: u8,
940 #[serde(default)]
942 pub severity_scores: SeverityScores,
943}
944
945fn default_block_threshold() -> u32 {
946 5
947}
948
949fn default_paranoia_level() -> u8 {
950 1
951}
952
953impl Default for ProxyConfig {
954 fn default() -> Self {
957 Self {
958 listen: "127.0.0.1:8080".parse().expect("valid loopback addr"),
959 backend: "http://localhost:8080".to_string(),
960 }
961 }
962}
963
964impl Default for WafConfig {
965 fn default() -> Self {
968 Self {
969 mode: WafMode::DetectionOnly,
970 block_threshold: default_block_threshold(),
971 paranoia_level: default_paranoia_level(),
972 severity_scores: SeverityScores::default(),
973 }
974 }
975}
976
977#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
978#[serde(rename_all = "kebab-case")]
979pub enum WafMode {
980 DetectionOnly,
981 Blocking,
982}
983
984#[derive(Debug, Clone, Deserialize)]
986#[non_exhaustive]
987pub struct LimitsConfig {
988 #[serde(default = "default_max_body_size")]
989 pub max_body_size: usize,
990 #[serde(default = "default_max_header_size")]
991 pub max_header_size: usize,
992 #[serde(default = "default_max_headers")]
993 pub max_headers: usize,
994 #[serde(default = "default_max_params")]
995 pub max_params: usize,
996 #[serde(default = "default_max_cookies")]
997 pub max_cookies: usize,
998 #[serde(default = "default_max_json_depth")]
999 pub max_json_depth: usize,
1000}
1001
1002fn default_max_body_size() -> usize { 1_048_576 } fn default_max_header_size() -> usize { 8_192 } fn default_max_headers() -> usize { 100 }
1005fn default_max_params() -> usize { 100 }
1006fn default_max_cookies() -> usize { 50 }
1007fn default_max_json_depth() -> usize { 20 }
1008
1009impl Default for LimitsConfig {
1010 fn default() -> Self {
1011 Self {
1012 max_body_size: default_max_body_size(),
1013 max_header_size: default_max_header_size(),
1014 max_headers: default_max_headers(),
1015 max_params: default_max_params(),
1016 max_cookies: default_max_cookies(),
1017 max_json_depth: default_max_json_depth(),
1018 }
1019 }
1020}
1021
1022#[derive(Debug, Clone, Default)]
1026pub enum ParsedBody {
1027 #[default]
1028 None,
1029 FormUrlEncoded(Vec<(String, String)>),
1030 Multipart(Vec<MultipartField>),
1031 JsonFlattened(Vec<(String, String)>),
1033 Raw(Bytes),
1034}
1035
1036#[derive(Debug, Clone)]
1037pub struct MultipartField {
1038 pub name: String,
1039 pub filename: Option<String>,
1040 pub content_type: Option<String>,
1041 pub data: Bytes,
1042}
1043
1044#[derive(Debug, Clone, Default)]
1048pub struct Normalized {
1049 pub path: String,
1051 pub query: Option<String>,
1053 pub query_params: Vec<(String, String)>,
1055 pub cookies: Vec<(String, String)>,
1057 pub headers: Vec<(String, String)>,
1059 pub body: ParsedBody,
1060 pub double_encoding_detected: bool,
1062 pub derived_decoded: Vec<String>,
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1080pub struct ScoreContribution {
1081 pub module: String,
1083 pub rule_id: String,
1085 pub severity: Option<Severity>,
1088 pub points: u32,
1090}
1091
1092#[cfg(test)]
1093mod scoring_serde_tests {
1094 use super::*;
1095
1096 #[test]
1100 fn score_contribution_json_shape_is_stable() {
1101 let items = vec![
1102 ScoreContribution {
1103 module: "sqli".to_string(),
1104 rule_id: "sqli-union-select".to_string(),
1105 severity: Some(Severity::Critical),
1106 points: 5,
1107 },
1108 ScoreContribution {
1109 module: "rate_limit".to_string(),
1110 rule_id: "rl-client-ip".to_string(),
1111 severity: None,
1112 points: 3,
1113 },
1114 ];
1115 let json = serde_json::to_string(&items).unwrap();
1116 assert_eq!(
1117 json,
1118 r#"[{"module":"sqli","rule_id":"sqli-union-select","severity":"critical","points":5},{"module":"rate_limit","rule_id":"rl-client-ip","severity":null,"points":3}]"#
1119 );
1120 let back: Vec<ScoreContribution> = serde_json::from_str(&json).unwrap();
1122 assert_eq!(back, items);
1123 }
1124}
1125
1126#[derive(Debug)]
1129pub struct RequestContext {
1130 pub client_ip: IpAddr,
1132 pub request_id: String,
1133 pub timestamp: SystemTime,
1134 pub method: String,
1136 pub path: String,
1137 pub raw_path: String,
1138 pub query: Option<String>,
1139 pub http_version: String,
1140 pub headers: Vec<(String, String)>,
1142 pub cookies: Vec<(String, String)>,
1143 pub body: Bytes,
1145 pub normalized: Normalized,
1147 pub score: u32,
1149 pub score_contributions: Vec<ScoreContribution>,
1151}
1152
1153#[cfg(test)]
1156mod config_validation_tests {
1157 use super::*;
1158
1159 fn valid() -> Config {
1160 Config {
1161 proxy: ProxyConfig {
1162 listen: "127.0.0.1:8080".parse().unwrap(),
1163 backend: "http://localhost:3000".to_string(),
1164 },
1165 waf: WafConfig {
1166 mode: WafMode::Blocking,
1167 block_threshold: 5,
1168 paranoia_level: 2,
1169 severity_scores: SeverityScores::default(),
1170 },
1171 limits: LimitsConfig::default(),
1172 modules: ModulesConfig::default(),
1173 rate_limit: RateLimitConfig::default(),
1174 network: NetworkConfig::default(),
1175 resilience: ResilienceConfig::default(),
1176 tls: TlsConfig::default(),
1177 metrics: MetricsConfig::default(),
1178 }
1179 }
1180
1181 #[test]
1182 fn valid_config_passes() {
1183 assert!(valid().validate().is_ok());
1184 }
1185
1186 #[test]
1187 fn paranoia_4_is_legal_forward_compatible() {
1188 let mut c = valid();
1189 c.waf.paranoia_level = MAX_PARANOIA_LEVEL; assert!(c.validate().is_ok());
1191 }
1192
1193 #[test]
1194 fn paranoia_out_of_range_rejected() {
1195 let mut c = valid();
1196 c.waf.paranoia_level = 0;
1197 assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(0)));
1198 c.waf.paranoia_level = 5;
1199 assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(5)));
1200 }
1201
1202 #[test]
1203 fn block_threshold_zero_rejected() {
1204 let mut c = valid();
1205 c.waf.block_threshold = 0;
1206 assert_eq!(c.validate(), Err(ConfigError::BlockThresholdZero));
1207 }
1208
1209 #[test]
1210 fn grpc_disabled_ignores_caps() {
1211 assert!(valid().validate().is_ok());
1213 }
1214
1215 #[test]
1216 fn grpc_enabled_rejects_zero_caps() {
1217 let mut c = valid();
1218 c.modules.grpc.enabled = true;
1219 assert!(c.validate().is_ok()); c.modules.grpc.max_depth = 0;
1221 assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_depth")));
1222 c.modules.grpc.max_depth = 16;
1223 c.modules.grpc.max_message_bytes = 0;
1224 assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_message_bytes")));
1225 }
1226
1227 #[test]
1228 fn tls_disabled_ignores_empty_paths() {
1229 assert!(valid().validate().is_ok());
1231 }
1232
1233 #[test]
1234 fn tls_enabled_requires_cert_and_key_paths() {
1235 let mut c = valid();
1236 c.tls.enabled = true;
1237 assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("cert_path")));
1238 c.tls.cert_path = "cert.pem".to_string();
1239 assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("key_path")));
1240 c.tls.key_path = "key.pem".to_string();
1241 assert!(c.validate().is_ok());
1242 }
1243
1244 #[test]
1245 fn tls_enabled_rejects_empty_alpn() {
1246 let mut c = valid();
1247 c.tls.enabled = true;
1248 c.tls.cert_path = "cert.pem".to_string();
1249 c.tls.key_path = "key.pem".to_string();
1250 c.tls.alpn = vec![];
1251 assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1252 c.tls.alpn = vec!["h2".to_string(), " ".to_string()];
1253 assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1254 }
1255
1256 #[test]
1257 fn crs_enabled_requires_files() {
1258 let mut c = valid();
1259 c.modules.crs.enabled = true;
1260 assert_eq!(c.validate(), Err(ConfigError::CrsNoFiles));
1261 c.modules.crs.files = vec!["rules.conf".to_string()];
1262 assert!(c.validate().is_ok());
1263 }
1264
1265 #[test]
1266 fn crs_disabled_ignores_empty_files() {
1267 assert!(valid().validate().is_ok());
1269 }
1270
1271 #[test]
1272 fn wasm_enabled_requires_plugins_with_paths() {
1273 let mut c = valid();
1274 c.modules.wasm.enabled = true;
1275 assert_eq!(c.validate(), Err(ConfigError::WasmNoPlugins));
1276 c.modules.wasm.plugins = vec![WasmPluginConfig { path: " ".into(), config: None }];
1277 assert_eq!(c.validate(), Err(ConfigError::WasmPluginPathEmpty));
1278 c.modules.wasm.plugins = vec![WasmPluginConfig { path: "f.wasm".into(), config: None }];
1279 assert!(c.validate().is_ok());
1280 }
1281
1282 #[test]
1283 fn wasm_disabled_ignores_empty_plugins() {
1284 assert!(valid().validate().is_ok());
1286 }
1287
1288 #[test]
1289 fn zero_severity_weight_rejected() {
1290 let mut c = valid();
1291 c.waf.severity_scores.warning = 0;
1292 assert_eq!(c.validate(), Err(ConfigError::SeverityWeightZero("warning")));
1293 }
1294
1295 #[test]
1296 fn zero_limit_rejected() {
1297 let mut c = valid();
1298 c.limits.max_json_depth = 0;
1299 assert_eq!(c.validate(), Err(ConfigError::LimitZero("max_json_depth")));
1300 }
1301
1302 #[test]
1303 fn invalid_backend_rejected() {
1304 let mut c = valid();
1305 c.proxy.backend = "localhost:3000".to_string(); assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1307 c.proxy.backend = "http://".to_string(); assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1309 }
1310
1311 #[test]
1312 fn rate_limit_values_checked_only_when_enabled() {
1313 let mut c = valid();
1314 c.rate_limit.enabled = false;
1316 c.rate_limit.requests = 0;
1317 assert!(c.validate().is_ok());
1318 c.rate_limit.enabled = true;
1320 assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("requests")));
1321 }
1322
1323 #[test]
1324 fn rate_limit_score_action_requires_positive_score() {
1325 let mut c = valid();
1326 c.rate_limit.enabled = true;
1327 c.rate_limit.action = RateLimitAction::Score;
1328 c.rate_limit.score = 0;
1329 assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("score")));
1330 }
1331
1332 #[test]
1333 fn trusted_hops_out_of_range_rejected() {
1334 let mut c = valid();
1335 c.network.trusted_hops = 0;
1336 assert_eq!(c.validate(), Err(ConfigError::TrustedHopsOutOfRange(0)));
1337 c.network.trusted_hops = MAX_TRUSTED_HOPS + 1;
1338 assert_eq!(
1339 c.validate(),
1340 Err(ConfigError::TrustedHopsOutOfRange(MAX_TRUSTED_HOPS + 1))
1341 );
1342 }
1343
1344 #[test]
1345 fn invalid_cidr_in_trusted_proxies_rejected() {
1346 let mut c = valid();
1347 c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "999.0.0.0/8".to_string()];
1348 assert_eq!(
1349 c.validate(),
1350 Err(ConfigError::InvalidCidr("999.0.0.0/8".to_string()))
1351 );
1352 }
1353
1354 #[test]
1355 fn valid_cidrs_pass() {
1356 let mut c = valid();
1357 c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "::1".to_string()];
1358 assert!(c.validate().is_ok());
1359 }
1360
1361 #[test]
1362 fn empty_client_ip_header_rejected() {
1363 let mut c = valid();
1364 c.network.client_ip_header = " ".to_string();
1365 assert_eq!(c.validate(), Err(ConfigError::EmptyClientIpHeader));
1366 }
1367
1368 #[test]
1369 fn zero_upstream_timeout_rejected() {
1370 let mut c = valid();
1371 c.resilience.upstream_timeout_ms = 0;
1372 assert_eq!(c.validate(), Err(ConfigError::ResilienceTimeoutZero));
1373 }
1374
1375 #[test]
1376 fn resilience_defaults_match_documented_posture() {
1377 let r = ResilienceConfig::default();
1378 assert_eq!(r.on_internal_error, FailMode::FailOpen);
1379 assert_eq!(r.on_upstream_error, FailMode::FailClosed);
1380 assert_eq!(r.on_config_error, FailMode::FailOpen);
1381 assert_eq!(r.on_parser_limit, FailMode::FailClosed);
1382 }
1383}