Skip to main content

waf_core/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use std::net::IpAddr;
5use std::time::SystemTime;
6
7use serde::Deserialize;
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// ── Decision / Phase / Module contract ───────────────────────────────────────
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Decision {
27    Allow,
28    Block { rule_id: String, reason: String },
29    Monitor { rule_id: String },
30    /// A single contribution with explicit points — used for high-confidence
31    /// rules or direct scoring where the module already knows the weight.
32    Score { rule_id: String, points: u32 },
33    /// Multiple contributions, one per matched rule, each carrying a severity.
34    /// The pipeline resolves `severity -> points` via `[waf.severity_scores]`,
35    /// so the cumulative anomaly score (CRS-style) sums every matched rule —
36    /// three Notice matches weigh more than one.
37    Scores(Vec<ScoreItem>),
38    /// Direct rejection with an explicit HTTP status — distinct from `Block`
39    /// (which is the 403 anomaly/high-confidence path). Used by rate limiting to
40    /// return 429 with an optional `Retry-After` (seconds). In detection-only the
41    /// pipeline logs it but does not reject.
42    Reject {
43        rule_id: String,
44        reason: String,
45        status: u16,
46        retry_after: Option<u64>,
47    },
48}
49
50/// One severity-tagged contribution emitted inside `Decision::Scores`.
51/// The module reports *what* it found (`rule_id` + `severity`); the pipeline
52/// owns the `severity -> points` policy.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ScoreItem {
55    pub rule_id: String,
56    pub severity: Severity,
57}
58
59/// Rule severity classes (CRS-inspired). The numeric weight of each class is
60/// configurable via `[waf.severity_scores]`, never hardcoded.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum Severity {
63    Critical,
64    Error,
65    Warning,
66    Notice,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum Phase {
71    Connection,
72    RequestLine,
73    Headers,
74    Body,
75    Response,
76}
77
78pub trait WafModule: Send + Sync {
79    fn id(&self) -> &str;
80    fn phase(&self) -> Phase;
81    /// Called once at startup; compile/init rules here, never in `inspect`.
82    fn init(&mut self, cfg: &Config);
83    /// Read-only access to context; pipeline owns mutation of `score`.
84    fn inspect(&self, ctx: &RequestContext) -> Decision;
85    /// `true` for a STRUCTURAL inspection module (e.g. GraphQL) whose decision does
86    /// NOT come from a content-rule match. The content fast-path (Pillar 3) may prove
87    /// "no content rule can match" and skip CONTENT inspection — but it cannot prove
88    /// a structural module is inert, so structural modules run even on the skip path.
89    /// Default `false` (a content module, gated by the fast-path).
90    fn structural(&self) -> bool {
91        false
92    }
93}
94
95// ── Config ────────────────────────────────────────────────────────────────────
96
97// `#[non_exhaustive]`: adding a future top-level section (as `tls` was added) must not
98// break external constructors. Cross-crate code can no longer use a `Config { .. }` literal
99// — it builds from `Config::default()` (or TOML) and mutates — so any new field is absorbed
100// by the default. This protects the whole tree transitively: every sub-config is reached
101// through a default/deserialized `Config`. The few sub-configs ALSO taken by-value in a
102// public fn (`TlsConfig`, `NetworkConfig`, `LimitsConfig`) are marked `#[non_exhaustive]`
103// individually; the rest stay literal-constructible.
104#[derive(Debug, Clone, Deserialize)]
105#[non_exhaustive]
106pub struct Config {
107    pub proxy: ProxyConfig,
108    pub waf: WafConfig,
109    #[serde(default)]
110    pub limits: LimitsConfig,
111    #[serde(default)]
112    pub modules: ModulesConfig,
113    #[serde(default)]
114    pub rate_limit: RateLimitConfig,
115    /// Shared network settings (trusted-proxy client-IP resolution). Reused by
116    /// rate limiting, structured logging and future Geo/IP-reputation.
117    #[serde(default)]
118    pub network: NetworkConfig,
119    /// Per-scenario behaviour when the WAF itself is in trouble.
120    #[serde(default)]
121    pub resilience: ResilienceConfig,
122    /// Inbound TLS termination (Phase 12). Default off → the listener stays cleartext,
123    /// exactly as before. Basic, cert-from-file termination is core (`BOUNDARY.md` §3.2);
124    /// cert management at scale (ACME/rotation/mTLS-PKI) is enterprise.
125    #[serde(default)]
126    pub tls: TlsConfig,
127    /// Prometheus metrics exposition (B1). Default off → no separate listener. Served on a
128    /// dedicated loopback port (never the data port — see ARCHITECTURE §9).
129    #[serde(default)]
130    pub metrics: MetricsConfig,
131}
132
133impl Default for Config {
134    /// A valid, non-disruptive base for programmatic/test construction: detection-only mode
135    /// (detects but never blocks), the standard detection modules ON at their defaults
136    /// (GraphQL/gRPC off — opt-in), rate limiting off, cleartext, metrics off. Production
137    /// always supplies `[proxy]`/`[waf]` from TOML; this default exists so external code never
138    /// needs a `Config { .. }` literal (forbidden by `#[non_exhaustive]`).
139    fn default() -> Self {
140        Self {
141            proxy: ProxyConfig::default(),
142            waf: WafConfig::default(),
143            limits: LimitsConfig::default(),
144            modules: ModulesConfig::default(),
145            rate_limit: RateLimitConfig::default(),
146            network: NetworkConfig::default(),
147            resilience: ResilienceConfig::default(),
148            tls: TlsConfig::default(),
149            metrics: MetricsConfig::default(),
150        }
151    }
152}
153
154// ── Metrics (B1: Prometheus exposition) ────────────────────────────────────────
155
156/// Prometheus `/metrics` exposition. **OPEN baseline** (`BOUNDARY.md` §1.6). Served on a
157/// SEPARATE loopback listener — never the data port, which would let the WAF inspect/expose
158/// its own internal posture (blocked/rate-limited volumes are an info leak if reachable).
159#[derive(Debug, Clone, Deserialize)]
160pub struct MetricsConfig {
161    /// Off by default → no metrics listener is bound.
162    #[serde(default)]
163    pub enabled: bool,
164    /// Where to serve `/metrics`. Default loopback (`127.0.0.1:9090`), never `0.0.0.0`.
165    #[serde(default = "default_metrics_listen")]
166    pub listen: std::net::SocketAddr,
167}
168
169fn default_metrics_listen() -> std::net::SocketAddr {
170    "127.0.0.1:9090".parse().expect("valid loopback addr")
171}
172
173impl Default for MetricsConfig {
174    fn default() -> Self {
175        Self { enabled: false, listen: default_metrics_listen() }
176    }
177}
178
179// ── TLS termination (Phase 12) ─────────────────────────────────────────────────
180
181/// Inbound TLS termination configuration. **Basic termination, cert from file** is the
182/// OPEN core surface (`BOUNDARY.md` §3.2): single-node self-sufficiency. ACME/rotation/
183/// multi-node certs / mTLS-with-managed-PKI are ENTERPRISE and plug in behind the
184/// `TlsCertSource` seam (see `waf-proxy::tls`).
185// `#[non_exhaustive]`: `TlsConfig` is taken by-value by public fns (`acceptor_from_config`/
186// `acceptor_from_source` in waf-proxy::tls), so it escapes the transitive protection of a
187// non-exhaustive `Config` — external callers must build it from `TlsConfig::default()`.
188#[derive(Debug, Clone, Deserialize)]
189#[non_exhaustive]
190pub struct TlsConfig {
191    /// Default OFF: the listener serves cleartext (h1 + h2c). When on, the listener
192    /// serves ONLY TLS — there is **no cleartext fallback** on the same port (a required
193    /// TLS that fails to build is a fatal boot error, never a silent downgrade).
194    #[serde(default)]
195    pub enabled: bool,
196    /// PEM file with the server certificate chain (leaf first).
197    #[serde(default)]
198    pub cert_path: String,
199    /// PEM file with the private key (PKCS#8 / PKCS#1 / SEC1).
200    #[serde(default)]
201    pub key_path: String,
202    /// ALPN protocols advertised, in preference order. Default `["h2","http/1.1"]` so an
203    /// h2-capable client (e.g. gRPC-over-TLS, Phase 13) negotiates HTTP/2 while an
204    /// h1-only client falls back cleanly.
205    #[serde(default = "default_tls_alpn")]
206    pub alpn: Vec<String>,
207}
208
209fn default_tls_alpn() -> Vec<String> {
210    vec!["h2".to_string(), "http/1.1".to_string()]
211}
212
213impl Default for TlsConfig {
214    fn default() -> Self {
215        Self {
216            enabled: false,
217            cert_path: String::new(),
218            key_path: String::new(),
219            alpn: default_tls_alpn(),
220        }
221    }
222}
223
224// ── Resilience (fail-open / fail-closed, per scenario) ─────────────────────────
225
226/// What to do when a given failure scenario occurs. Uniform across scenarios for
227/// schema consistency, but the *meaning* of `FailOpen` is scenario-specific — see
228/// `ResilienceConfig` and ARCHITECTURE §9.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum FailMode {
232    /// Favour availability.
233    FailOpen,
234    /// Favour security / surface the error.
235    FailClosed,
236}
237
238/// Explicit, per-scenario failure policy. No single global boolean: each kind of
239/// trouble has its own correct posture (see the defaults' rationale in §9).
240#[derive(Debug, Clone, Copy, Deserialize)]
241pub struct ResilienceConfig {
242    /// Origin unreachable / timeout. `fail_closed` → 502, `fail_open` → 503
243    /// (retryable). NB: `fail_open` here does NOT mean "let traffic through"
244    /// (there is no origin to reach) — only which 5xx is returned.
245    #[serde(default = "default_on_upstream_error")]
246    pub on_upstream_error: FailMode,
247    /// Module panic / regex blow-up. `fail_open` → skip the module and continue
248    /// (a WAF bug must not take down the site); `fail_closed` → synthetic block.
249    #[serde(default = "default_on_internal_error")]
250    pub on_internal_error: FailMode,
251    /// Invalid config detected at runtime (hot reload, Pillar 3). `fail_open` →
252    /// keep last-good config; `fail_closed` → refuse serving until valid.
253    #[serde(default = "default_on_config_error")]
254    pub on_config_error: FailMode,
255    /// Normalization failed (limits exceeded / malformed input). `fail_closed` →
256    /// 400; `fail_open` → forward UNINSPECTED (logged loudly).
257    #[serde(default = "default_on_parser_limit")]
258    pub on_parser_limit: FailMode,
259    /// Hard cap on the upstream round-trip so a stalled origin cannot pin the
260    /// worker. Must be >= 1 (validated).
261    #[serde(default = "default_upstream_timeout_ms")]
262    pub upstream_timeout_ms: u64,
263}
264
265fn default_on_upstream_error() -> FailMode { FailMode::FailClosed }
266fn default_on_internal_error() -> FailMode { FailMode::FailOpen }
267fn default_on_config_error() -> FailMode { FailMode::FailOpen }
268fn default_on_parser_limit() -> FailMode { FailMode::FailClosed }
269fn default_upstream_timeout_ms() -> u64 { 30_000 }
270
271impl Default for ResilienceConfig {
272    fn default() -> Self {
273        Self {
274            on_upstream_error: default_on_upstream_error(),
275            on_internal_error: default_on_internal_error(),
276            on_config_error: default_on_config_error(),
277            on_parser_limit: default_on_parser_limit(),
278            upstream_timeout_ms: default_upstream_timeout_ms(),
279        }
280    }
281}
282
283impl ResilienceConfig {
284    /// Upstream round-trip cap as a `Duration`.
285    pub fn upstream_timeout(&self) -> std::time::Duration {
286        std::time::Duration::from_millis(self.upstream_timeout_ms)
287    }
288}
289
290// ── Network (shared client-IP resolution) ─────────────────────────────────────
291
292/// Trusted-proxy configuration for resolving the real client IP behind an
293/// LB/CDN/TLS-terminator. See `network::ClientIpResolver` for the logic.
294// `#[non_exhaustive]`: taken by-value by the public `ClientIpResolver::from_config`.
295#[derive(Debug, Clone, Deserialize)]
296#[non_exhaustive]
297pub struct NetworkConfig {
298    /// CIDR blocks of YOUR proxies (IPv4 or IPv6). Empty (default) = fail-safe:
299    /// the forwarded header is ALWAYS ignored and the peer address is used.
300    #[serde(default)]
301    pub trusted_proxies: Vec<String>,
302    /// Header carrying the forwarded chain (default `x-forwarded-for`).
303    #[serde(default = "default_client_ip_header")]
304    pub client_ip_header: String,
305    /// How many hops, counted from the RIGHT of the chain, to trust. Never the
306    /// leftmost IP (that one is client-controlled and spoofable).
307    #[serde(default = "default_trusted_hops")]
308    pub trusted_hops: usize,
309}
310
311fn default_client_ip_header() -> String { "x-forwarded-for".to_string() }
312fn default_trusted_hops() -> usize { 1 }
313
314impl Default for NetworkConfig {
315    fn default() -> Self {
316        Self {
317            trusted_proxies: Vec::new(),
318            client_ip_header: default_client_ip_header(),
319            trusted_hops: default_trusted_hops(),
320        }
321    }
322}
323
324// ── Semantic validation (reusable by startup load AND hot reload) ──────────────
325
326/// Highest paranoia level the contract allows. The validator guards the legal
327/// space of the CONTRACT (see ARCHITECTURE §7), not the current rule set — PL4 is
328/// forward-compatible even if no rule uses it yet.
329pub const MAX_PARANOIA_LEVEL: u8 = 4;
330/// Upper bound on `trusted_hops`: real proxy chains are short; a huge value is a
331/// configuration mistake (and would make every request fall back to the peer).
332pub const MAX_TRUSTED_HOPS: usize = 10;
333
334/// A semantic configuration error. Distinct from TOML *syntax* errors (handled at
335/// parse time) and from I/O errors (file missing/unreadable).
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum ConfigError {
338    InvalidBackend(String),
339    BlockThresholdZero,
340    ParanoiaOutOfRange(u8),
341    SeverityWeightZero(&'static str),
342    LimitZero(&'static str),
343    RateLimitValueZero(&'static str),
344    TrustedHopsOutOfRange(usize),
345    InvalidCidr(String),
346    EmptyClientIpHeader,
347    ResilienceTimeoutZero,
348    GraphqlCapZero(&'static str),
349    GrpcCapZero(&'static str),
350    TlsPathEmpty(&'static str),
351    TlsAlpnInvalid,
352    CrsNoFiles,
353    WasmNoPlugins,
354    WasmPluginPathEmpty,
355}
356
357impl std::fmt::Display for ConfigError {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        match self {
360            Self::InvalidBackend(b) => write!(
361                f,
362                "proxy.backend must be an absolute http(s) URL with a host, got {b:?}"
363            ),
364            Self::BlockThresholdZero =>
365                write!(f, "waf.block_threshold must be >= 1 (0 would block everything)"),
366            Self::ParanoiaOutOfRange(p) =>
367                write!(f, "waf.paranoia_level must be in 1..={MAX_PARANOIA_LEVEL}, got {p}"),
368            Self::SeverityWeightZero(name) =>
369                write!(f, "waf.severity_scores.{name} must be >= 1 (a 0 weight makes the rule contribute nothing)"),
370            Self::LimitZero(name) =>
371                write!(f, "limits.{name} must be >= 1"),
372            Self::RateLimitValueZero(name) =>
373                write!(f, "rate_limit.{name} must be >= 1 when rate limiting is enabled"),
374            Self::TrustedHopsOutOfRange(h) =>
375                write!(f, "network.trusted_hops must be in 1..={MAX_TRUSTED_HOPS}, got {h}"),
376            Self::InvalidCidr(c) =>
377                write!(f, "network.trusted_proxies contains an invalid CIDR: {c:?}"),
378            Self::EmptyClientIpHeader =>
379                write!(f, "network.client_ip_header must not be empty"),
380            Self::ResilienceTimeoutZero =>
381                write!(f, "resilience.upstream_timeout_ms must be >= 1"),
382            Self::GraphqlCapZero(name) =>
383                write!(f, "modules.graphql.{name} must be >= 1 when the graphql module is enabled"),
384            Self::GrpcCapZero(name) =>
385                write!(f, "modules.grpc.{name} must be >= 1 when the grpc module is enabled"),
386            Self::TlsPathEmpty(name) =>
387                write!(f, "tls.{name} must be set (a PEM file path) when tls is enabled"),
388            Self::TlsAlpnInvalid =>
389                write!(f, "tls.alpn must be a non-empty list of non-empty protocol ids (e.g. \"h2\", \"http/1.1\")"),
390            Self::CrsNoFiles =>
391                write!(f, "modules.crs.files must list at least one file when the crs module is enabled"),
392            Self::WasmNoPlugins =>
393                write!(f, "modules.wasm.plugins must list at least one plugin when the wasm module is enabled"),
394            Self::WasmPluginPathEmpty =>
395                write!(f, "modules.wasm.plugins[].path must be a non-empty .wasm file path"),
396        }
397    }
398}
399
400impl std::error::Error for ConfigError {}
401
402impl Config {
403    /// Semantic validation, separate from TOML parsing. Called at startup (after
404    /// parse, before build) and reused by hot reload. Fails fast with a specific
405    /// error rather than starting with values that look valid but aren't.
406    pub fn validate(&self) -> Result<(), ConfigError> {
407        // proxy.backend: absolute http(s) URL with a non-empty authority.
408        let backend = self.proxy.backend.trim();
409        let authority = backend
410            .strip_prefix("http://")
411            .or_else(|| backend.strip_prefix("https://"));
412        match authority {
413            Some(a) if !a.is_empty() && !a.starts_with('/') => {}
414            _ => return Err(ConfigError::InvalidBackend(self.proxy.backend.clone())),
415        }
416
417        // waf scoring knobs.
418        if self.waf.block_threshold == 0 {
419            return Err(ConfigError::BlockThresholdZero);
420        }
421        if !(1..=MAX_PARANOIA_LEVEL).contains(&self.waf.paranoia_level) {
422            return Err(ConfigError::ParanoiaOutOfRange(self.waf.paranoia_level));
423        }
424        let s = &self.waf.severity_scores;
425        for (name, v) in [
426            ("critical", s.critical),
427            ("error", s.error),
428            ("warning", s.warning),
429            ("notice", s.notice),
430        ] {
431            if v == 0 {
432                return Err(ConfigError::SeverityWeightZero(name));
433            }
434        }
435
436        // Defensive limits: a 0 breaks parsing/inspection.
437        let l = &self.limits;
438        for (name, v) in [
439            ("max_body_size", l.max_body_size),
440            ("max_header_size", l.max_header_size),
441            ("max_headers", l.max_headers),
442            ("max_params", l.max_params),
443            ("max_cookies", l.max_cookies),
444            ("max_json_depth", l.max_json_depth),
445        ] {
446            if v == 0 {
447                return Err(ConfigError::LimitZero(name));
448            }
449        }
450
451        // Rate limiting: only meaningful when enabled.
452        if self.rate_limit.enabled {
453            let r = &self.rate_limit;
454            if r.requests == 0 {
455                return Err(ConfigError::RateLimitValueZero("requests"));
456            }
457            if r.window_seconds == 0 {
458                return Err(ConfigError::RateLimitValueZero("window_seconds"));
459            }
460            if matches!(r.burst, Some(0)) {
461                return Err(ConfigError::RateLimitValueZero("burst"));
462            }
463            if r.max_tracked_keys == 0 {
464                return Err(ConfigError::RateLimitValueZero("max_tracked_keys"));
465            }
466            if r.action == RateLimitAction::Score && r.score == 0 {
467                return Err(ConfigError::RateLimitValueZero("score"));
468            }
469        }
470
471        // Network / client-IP resolution.
472        if !(1..=MAX_TRUSTED_HOPS).contains(&self.network.trusted_hops) {
473            return Err(ConfigError::TrustedHopsOutOfRange(self.network.trusted_hops));
474        }
475        for cidr in &self.network.trusted_proxies {
476            if !network::is_valid_cidr(cidr) {
477                return Err(ConfigError::InvalidCidr(cidr.clone()));
478            }
479        }
480        if self.network.client_ip_header.trim().is_empty() {
481            return Err(ConfigError::EmptyClientIpHeader);
482        }
483
484        // Resilience.
485        if self.resilience.upstream_timeout_ms == 0 {
486            return Err(ConfigError::ResilienceTimeoutZero);
487        }
488
489        // TLS: paths/ALPN are only meaningful when enabled. File existence + cert/key
490        // parsing happen at bind time (I/O, fs-free validate stays reload-safe); a
491        // required-but-unreadable cert is a fatal boot error (see waf-proxy::tls).
492        if self.tls.enabled {
493            if self.tls.cert_path.trim().is_empty() {
494                return Err(ConfigError::TlsPathEmpty("cert_path"));
495            }
496            if self.tls.key_path.trim().is_empty() {
497                return Err(ConfigError::TlsPathEmpty("key_path"));
498            }
499            if self.tls.alpn.is_empty() || self.tls.alpn.iter().any(|p| p.trim().is_empty()) {
500                return Err(ConfigError::TlsAlpnInvalid);
501            }
502        }
503
504        // GraphQL caps: a 0 cap would reject every query → only meaningful when enabled.
505        if self.modules.graphql.enabled {
506            let g = &self.modules.graphql;
507            for (name, v) in [
508                ("max_depth", g.max_depth),
509                ("max_aliases", g.max_aliases),
510                ("max_fields", g.max_fields),
511                ("max_directives", g.max_directives),
512                ("max_batch", g.max_batch),
513            ] {
514                if v == 0 {
515                    return Err(ConfigError::GraphqlCapZero(name));
516                }
517            }
518        }
519
520        // gRPC caps: a 0 cap would reject every message → only meaningful when enabled.
521        if self.modules.grpc.enabled {
522            let g = &self.modules.grpc;
523            for (name, zero) in [
524                ("max_message_bytes", g.max_message_bytes == 0),
525                ("max_fields", g.max_fields == 0),
526                ("max_depth", g.max_depth == 0),
527            ] {
528                if zero {
529                    return Err(ConfigError::GrpcCapZero(name));
530                }
531            }
532        }
533
534        // CRS import: a path list is required when enabled (file I/O is deferred to boot,
535        // keeping `validate` fs-free and reload-safe — like TLS).
536        if self.modules.crs.enabled && self.modules.crs.files.is_empty() {
537            return Err(ConfigError::CrsNoFiles);
538        }
539
540        // WASM runtime: at least one plugin when enabled, each with a non-empty path
541        // (file I/O deferred to boot, keeping `validate` fs-free and reload-safe).
542        if self.modules.wasm.enabled {
543            if self.modules.wasm.plugins.is_empty() {
544                return Err(ConfigError::WasmNoPlugins);
545            }
546            if self.modules.wasm.plugins.iter().any(|p| p.path.trim().is_empty()) {
547                return Err(ConfigError::WasmPluginPathEmpty);
548            }
549        }
550
551        Ok(())
552    }
553}
554
555// ── Rate limiting (L7, on_connection) ─────────────────────────────────────────
556
557/// Which request attribute the rate limiter buckets on. `client_ip` is the peer
558/// socket address; behind an LB/CDN this collapses to the proxy IP — see §8.
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
560#[serde(rename_all = "snake_case")]
561pub enum RateLimitKey {
562    #[default]
563    ClientIp,
564}
565
566/// What happens when a key exceeds its budget.
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
568#[serde(rename_all = "snake_case")]
569pub enum RateLimitAction {
570    /// Reject immediately with HTTP 429 (`Decision::Reject`).
571    #[default]
572    Block,
573    /// Contribute `score` points to the cumulative anomaly score instead.
574    Score,
575}
576
577#[derive(Debug, Clone, Deserialize)]
578pub struct RateLimitConfig {
579    /// Off when the `[rate_limit]` section is absent (fail-safe default).
580    #[serde(default)]
581    pub enabled: bool,
582    #[serde(default)]
583    pub key: RateLimitKey,
584    /// Token refill budget per window (tokens added per `window_seconds`).
585    #[serde(default = "default_rl_requests")]
586    pub requests: u32,
587    #[serde(default = "default_rl_window")]
588    pub window_seconds: u64,
589    /// Bucket capacity (max burst). Defaults to `requests` when omitted.
590    #[serde(default)]
591    pub burst: Option<u32>,
592    #[serde(default)]
593    pub action: RateLimitAction,
594    /// Points added when `action = "score"` and the budget is exceeded.
595    #[serde(default = "default_rl_score")]
596    pub score: u32,
597    /// Memory cap: when the tracked-key map reaches this size, idle (fully
598    /// refilled) buckets are swept before inserting a new key.
599    #[serde(default = "default_rl_max_keys")]
600    pub max_tracked_keys: usize,
601}
602
603fn default_rl_requests() -> u32 { 100 }
604fn default_rl_window() -> u64 { 60 }
605fn default_rl_score() -> u32 { 5 }
606fn default_rl_max_keys() -> usize { 100_000 }
607
608impl Default for RateLimitConfig {
609    fn default() -> Self {
610        Self {
611            enabled: false,
612            key: RateLimitKey::ClientIp,
613            requests: default_rl_requests(),
614            window_seconds: default_rl_window(),
615            burst: None,
616            action: RateLimitAction::Block,
617            score: default_rl_score(),
618            max_tracked_keys: default_rl_max_keys(),
619        }
620    }
621}
622
623#[derive(Debug, Clone, Deserialize, Default)]
624pub struct ModulesConfig {
625    #[serde(default)]
626    pub sqli: ModuleConfig,
627    #[serde(default)]
628    pub xss: ModuleConfig,
629    #[serde(default)]
630    pub path_traversal: ModuleConfig,
631    #[serde(default)]
632    pub rce: ModuleConfig,
633    #[serde(default)]
634    pub lfi_rfi: ModuleConfig,
635    #[serde(default)]
636    pub ssrf: ModuleConfig,
637    #[serde(default)]
638    pub ldap: ModuleConfig,
639    #[serde(default)]
640    pub nosql: ModuleConfig,
641    #[serde(default)]
642    pub mail: ModuleConfig,
643    #[serde(default)]
644    pub ssti: ModuleConfig,
645    #[serde(default)]
646    pub scanner: ModuleConfig,
647    #[serde(default)]
648    pub ssi: ModuleConfig,
649    #[serde(default)]
650    pub xxe: ModuleConfig,
651    #[serde(default)]
652    pub header_injection: ModuleConfig,
653    /// HTTP request-smuggling framing checks (CL/TE). Structural security control,
654    /// default on (see ARCHITECTURE §8).
655    #[serde(default)]
656    pub request_smuggling: ModuleConfig,
657    /// GraphQL structural protections (depth / aliases / fields / directives / batch /
658    /// introspection). A structural control like request_smuggling — NOT content-regex.
659    /// Default OFF: it is endpoint-specific and the caps need per-app tuning (Phase 11).
660    #[serde(default)]
661    pub graphql: GraphqlConfig,
662    /// gRPC structural protections (message size / field count / nesting depth) + a
663    /// compressed-payload policy. Structural, like request_smuggling/graphql — the
664    /// CONTENT of protobuf fields is inspected by the normal modules via the §6 derived
665    /// channel. Default OFF: gRPC needs HTTP/2 and the caps are per-app (gRPC phase).
666    #[serde(default)]
667    pub grpc: GrpcConfig,
668    /// Imported OWASP CRS / ModSecurity rules (B2). The core ships the *parser/engine*;
669    /// the curated rule CONTENT is the operator's (or enterprise §2.4). Default OFF.
670    #[serde(default)]
671    pub crs: CrsConfig,
672    /// Proxy-Wasm plugin runtime (B3). The core ships the *runtime*; marketplace/signing is
673    /// enterprise (§2.4). Each plugin is a `.wasm` filter run as a `WafModule`. Default OFF.
674    #[serde(default)]
675    pub wasm: WasmConfig,
676}
677
678/// CRS/ModSecurity rule import (B2). Loads `SecRule …` files at boot and runs the
679/// supported subset as a `WafModule`. The parser is the OPEN baseline (`BOUNDARY.md`
680/// §1.7); rules a file uses that fall outside the v1 subset are skipped and reported at
681/// boot, never silently dropped.
682#[derive(Debug, Clone, Default, Deserialize)]
683pub struct CrsConfig {
684    /// Default OFF (opt-in per deployment).
685    #[serde(default)]
686    pub enabled: bool,
687    /// `seclang` files to import, in include order. Read at boot; an unreadable file is
688    /// logged and skipped (the module still loads the readable ones).
689    #[serde(default)]
690    pub files: Vec<String>,
691}
692
693/// Proxy-Wasm plugin runtime (B3). Loads one or more `.wasm` filters at boot and runs each
694/// as a `WafModule` on every request (`structural`). The runtime is the OPEN baseline
695/// (`BOUNDARY.md` §1.7); a plugin whose `.wasm` cannot be compiled/instantiated is logged
696/// and skipped (fail-open at load, like CRS) while the per-request posture is fail-closed.
697#[derive(Debug, Clone, Deserialize)]
698pub struct WasmConfig {
699    /// Default OFF (opt-in per deployment).
700    #[serde(default)]
701    pub enabled: bool,
702    /// Pre-instantiated guest instances per plugin (the request concurrency a plugin can
703    /// serve before callers queue on checkout).
704    #[serde(default = "default_wasm_pool_size")]
705    pub pool_size: usize,
706    /// Per-request fuel budget — the plugin's CPU/latency ceiling (it runs on every
707    /// request). Exhaustion traps and fails closed.
708    #[serde(default = "default_wasm_fuel")]
709    pub fuel_per_request: u64,
710    /// Per-instance linear-memory cap (bytes). Growth beyond it is denied → fail closed.
711    #[serde(default = "default_wasm_max_memory")]
712    pub max_memory_bytes: usize,
713    /// How long a request waits for a free instance before failing closed (pool exhaustion).
714    #[serde(default = "default_wasm_checkout_timeout_ms")]
715    pub checkout_timeout_ms: u64,
716    /// The plugins to load, in order.
717    #[serde(default)]
718    pub plugins: Vec<WasmPluginConfig>,
719}
720
721fn default_wasm_pool_size() -> usize { 4 }
722fn default_wasm_fuel() -> u64 { 10_000_000 }
723fn default_wasm_max_memory() -> usize { 16 * 1024 * 1024 }
724fn default_wasm_checkout_timeout_ms() -> u64 { 100 }
725
726impl Default for WasmConfig {
727    fn default() -> Self {
728        WasmConfig {
729            enabled: false,
730            pool_size: default_wasm_pool_size(),
731            fuel_per_request: default_wasm_fuel(),
732            max_memory_bytes: default_wasm_max_memory(),
733            checkout_timeout_ms: default_wasm_checkout_timeout_ms(),
734            plugins: Vec::new(),
735        }
736    }
737}
738
739/// One Proxy-Wasm plugin: a `.wasm` path plus an opaque configuration string passed to the
740/// guest's `proxy_on_configure`.
741#[derive(Debug, Clone, Default, Deserialize)]
742pub struct WasmPluginConfig {
743    pub path: String,
744    #[serde(default)]
745    pub config: Option<String>,
746}
747
748#[derive(Debug, Clone, Deserialize)]
749pub struct ModuleConfig {
750    #[serde(default = "default_true")]
751    pub enabled: bool,
752}
753
754fn default_true() -> bool { true }
755
756impl Default for ModuleConfig {
757    fn default() -> Self {
758        Self { enabled: true }
759    }
760}
761
762/// GraphQL module configuration (Phase 11). Structural DoS/abuse caps applied to the
763/// GraphQL operation(s) carried by a request (JSON `query` field, `application/graphql`
764/// raw body, or GET `?query=`). All counts come from the lexical [`graphql_lex`] pass.
765#[derive(Debug, Clone, Deserialize)]
766pub struct GraphqlConfig {
767    /// Default OFF (opt-in per deployment).
768    #[serde(default)]
769    pub enabled: bool,
770    /// Request paths treated as GraphQL endpoints (exact, case-insensitive). JSON and
771    /// GET transports are inspected ONLY on these paths (so a non-GraphQL JSON API with
772    /// a `query` field is not affected); `application/graphql` is recognized by its
773    /// Content-Type regardless of path.
774    #[serde(default = "default_graphql_paths")]
775    pub paths: Vec<String>,
776    /// Max selection-set nesting depth (paren-aware).
777    #[serde(default = "default_graphql_max_depth")]
778    pub max_depth: u32,
779    /// Max alias count (`alias: field`) — the "alias bomb" cap.
780    #[serde(default = "default_graphql_max_aliases")]
781    pub max_aliases: u32,
782    /// Max selection-name count (a cheap complexity proxy).
783    #[serde(default = "default_graphql_max_fields")]
784    pub max_fields: u32,
785    /// Max `@directive` count.
786    #[serde(default = "default_graphql_max_directives")]
787    pub max_directives: u32,
788    /// Max number of operations in one (batched) request.
789    #[serde(default = "default_graphql_max_batch")]
790    pub max_batch: u32,
791    /// Block schema introspection (`__schema`/`__type`) → 403. Default off (policy).
792    #[serde(default)]
793    pub block_introspection: bool,
794}
795
796fn default_graphql_paths() -> Vec<String> { vec!["/graphql".to_string()] }
797fn default_graphql_max_depth() -> u32 { 15 }
798fn default_graphql_max_aliases() -> u32 { 30 }
799fn default_graphql_max_fields() -> u32 { 1000 }
800fn default_graphql_max_directives() -> u32 { 50 }
801fn default_graphql_max_batch() -> u32 { 10 }
802
803impl Default for GraphqlConfig {
804    fn default() -> Self {
805        Self {
806            enabled: false,
807            paths: default_graphql_paths(),
808            max_depth: default_graphql_max_depth(),
809            max_aliases: default_graphql_max_aliases(),
810            max_fields: default_graphql_max_fields(),
811            max_directives: default_graphql_max_directives(),
812            max_batch: default_graphql_max_batch(),
813            block_introspection: false,
814        }
815    }
816}
817
818/// What to do with a gRPC message whose payload is COMPRESSED (per-message flag set or a
819/// non-identity `grpc-encoding`): its bytes are opaque to the WAF, so it cannot be
820/// inspected. `Reject` (default) is fail-closed — a `gzip` payload you let through is a
821/// trivial bypass (compress the attack, skip the WAF). `Passthrough` forwards it
822/// UNINSPECTED — a deliberate, on-record choice for a backend that requires compression.
823#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
824#[serde(rename_all = "snake_case")]
825pub enum CompressedPolicy {
826    Reject,
827    Passthrough,
828}
829
830/// gRPC module configuration (gRPC phase). Structural DoS/abuse caps on the framed
831/// protobuf body (message size / field count / nesting depth) + a compressed-payload
832/// policy. Structural only — protobuf field CONTENT flows to the normal content modules
833/// via the §6 derived channel, not through this module. Counts come from the
834/// [`grpc_extract`](../waf_normalizer/grpc/fn.grpc_extract.html) pass.
835#[derive(Debug, Clone, Deserialize)]
836pub struct GrpcConfig {
837    /// Default OFF (opt-in per deployment; needs HTTP/2).
838    #[serde(default)]
839    pub enabled: bool,
840    /// Max total inspectable payload bytes across the framed messages in one request.
841    #[serde(default = "default_grpc_max_message_bytes")]
842    pub max_message_bytes: u64,
843    /// Max protobuf field count (field-bomb cap).
844    #[serde(default = "default_grpc_max_fields")]
845    pub max_fields: u32,
846    /// Max sub-message nesting depth (depth-bomb cap).
847    #[serde(default = "default_grpc_max_depth")]
848    pub max_depth: u32,
849    /// What to do with a compressed (un-inspectable) payload. Default `reject` (fail-closed).
850    #[serde(default = "default_grpc_on_compressed")]
851    pub on_compressed: CompressedPolicy,
852}
853
854fn default_grpc_max_message_bytes() -> u64 { 4 * 1024 * 1024 }
855fn default_grpc_max_fields() -> u32 { 4096 }
856fn default_grpc_max_depth() -> u32 { 16 }
857fn default_grpc_on_compressed() -> CompressedPolicy { CompressedPolicy::Reject }
858
859impl Default for GrpcConfig {
860    fn default() -> Self {
861        Self {
862            enabled: false,
863            max_message_bytes: default_grpc_max_message_bytes(),
864            max_fields: default_grpc_max_fields(),
865            max_depth: default_grpc_max_depth(),
866            on_compressed: default_grpc_on_compressed(),
867        }
868    }
869}
870
871/// Points assigned to each severity class. Replaces per-module hardcoded scores;
872/// changing these values directly changes how much each match contributes.
873#[derive(Debug, Clone, Copy, Deserialize)]
874pub struct SeverityScores {
875    #[serde(default = "default_critical_score")]
876    pub critical: u32,
877    #[serde(default = "default_error_score")]
878    pub error: u32,
879    #[serde(default = "default_warning_score")]
880    pub warning: u32,
881    #[serde(default = "default_notice_score")]
882    pub notice: u32,
883}
884
885// Critical raised 5 → 6 (Fase 7 / Pilastro 2, config C2): a single high-confidence
886// rule blocks with own-merit margin >= 1 over the default block_threshold (5), while
887// Warning/Notice stay sub-threshold (accumulation-only). Validated on the corpus by
888// waf-corpus `tests/validation.rs` (RECOMMENDED_SEVERITY); rationale in ARCHITECTURE §7.
889fn default_critical_score() -> u32 { 6 }
890fn default_error_score() -> u32 { 4 }
891fn default_warning_score() -> u32 { 3 }
892fn default_notice_score() -> u32 { 2 }
893
894impl Default for SeverityScores {
895    fn default() -> Self {
896        Self {
897            critical: default_critical_score(),
898            error: default_error_score(),
899            warning: default_warning_score(),
900            notice: default_notice_score(),
901        }
902    }
903}
904
905impl SeverityScores {
906    /// Resolve a severity class to its configured point weight.
907    pub fn points_for(&self, severity: Severity) -> u32 {
908        match severity {
909            Severity::Critical => self.critical,
910            Severity::Error => self.error,
911            Severity::Warning => self.warning,
912            Severity::Notice => self.notice,
913        }
914    }
915}
916
917#[derive(Debug, Clone, Deserialize)]
918pub struct ProxyConfig {
919    pub listen: std::net::SocketAddr,
920    pub backend: String,
921}
922
923#[derive(Debug, Clone, Deserialize)]
924pub struct WafConfig {
925    pub mode: WafMode,
926    // NOTE: the old `fail_open: bool` was removed in favour of the per-scenario
927    // `[resilience]` section. A leftover `waf.fail_open` in TOML is rejected with
928    // a migration hint at load time (see waf-proxy::config), never silently.
929    #[serde(default = "default_block_threshold")]
930    pub block_threshold: u32,
931    /// Paranoia level (1..=4). Higher levels activate more (and noisier) rules.
932    #[serde(default = "default_paranoia_level")]
933    pub paranoia_level: u8,
934    /// Point weight per severity class. Used by the pipeline to score matches.
935    #[serde(default)]
936    pub severity_scores: SeverityScores,
937}
938
939fn default_block_threshold() -> u32 {
940    5
941}
942
943fn default_paranoia_level() -> u8 {
944    1
945}
946
947impl Default for ProxyConfig {
948    /// Loopback placeholder for programmatic/test construction; production supplies
949    /// `[proxy]` from TOML.
950    fn default() -> Self {
951        Self {
952            listen: "127.0.0.1:8080".parse().expect("valid loopback addr"),
953            backend: "http://localhost:8080".to_string(),
954        }
955    }
956}
957
958impl Default for WafConfig {
959    /// Detection-only, default thresholds — a non-disruptive base (never blocks until
960    /// explicitly switched to `Blocking`).
961    fn default() -> Self {
962        Self {
963            mode: WafMode::DetectionOnly,
964            block_threshold: default_block_threshold(),
965            paranoia_level: default_paranoia_level(),
966            severity_scores: SeverityScores::default(),
967        }
968    }
969}
970
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
972#[serde(rename_all = "kebab-case")]
973pub enum WafMode {
974    DetectionOnly,
975    Blocking,
976}
977
978// `#[non_exhaustive]`: taken by-value by the public `Normalizer::new(&LimitsConfig)`.
979#[derive(Debug, Clone, Deserialize)]
980#[non_exhaustive]
981pub struct LimitsConfig {
982    #[serde(default = "default_max_body_size")]
983    pub max_body_size: usize,
984    #[serde(default = "default_max_header_size")]
985    pub max_header_size: usize,
986    #[serde(default = "default_max_headers")]
987    pub max_headers: usize,
988    #[serde(default = "default_max_params")]
989    pub max_params: usize,
990    #[serde(default = "default_max_cookies")]
991    pub max_cookies: usize,
992    #[serde(default = "default_max_json_depth")]
993    pub max_json_depth: usize,
994}
995
996fn default_max_body_size() -> usize { 1_048_576 } // 1 MiB
997fn default_max_header_size() -> usize { 8_192 }   // 8 KiB
998fn default_max_headers() -> usize { 100 }
999fn default_max_params() -> usize { 100 }
1000fn default_max_cookies() -> usize { 50 }
1001fn default_max_json_depth() -> usize { 20 }
1002
1003impl Default for LimitsConfig {
1004    fn default() -> Self {
1005        Self {
1006            max_body_size: default_max_body_size(),
1007            max_header_size: default_max_header_size(),
1008            max_headers: default_max_headers(),
1009            max_params: default_max_params(),
1010            max_cookies: default_max_cookies(),
1011            max_json_depth: default_max_json_depth(),
1012        }
1013    }
1014}
1015
1016// ── Parsed / Normalized types ─────────────────────────────────────────────────
1017
1018/// Parsed body, populated by the normalizer based on Content-Type.
1019#[derive(Debug, Clone, Default)]
1020pub enum ParsedBody {
1021    #[default]
1022    None,
1023    FormUrlEncoded(Vec<(String, String)>),
1024    Multipart(Vec<MultipartField>),
1025    /// JSON flattened to (dot-path, string-value) pairs for pattern inspection.
1026    JsonFlattened(Vec<(String, String)>),
1027    Raw(Bytes),
1028}
1029
1030#[derive(Debug, Clone)]
1031pub struct MultipartField {
1032    pub name: String,
1033    pub filename: Option<String>,
1034    pub content_type: Option<String>,
1035    pub data: Bytes,
1036}
1037
1038/// Canonicalized version of all inspectable fields.
1039/// Populated by the normalizer before any detection module runs.
1040/// Raw originals remain in RequestContext fields.
1041#[derive(Debug, Clone, Default)]
1042pub struct Normalized {
1043    /// URL-decoded, traversal-resolved, lowercased path.
1044    pub path: String,
1045    /// URL-decoded query string (raw, single decode).
1046    pub query: Option<String>,
1047    /// Parsed, decoded query parameters — repeated names are all kept.
1048    pub query_params: Vec<(String, String)>,
1049    /// Parsed cookies (from Cookie headers).
1050    pub cookies: Vec<(String, String)>,
1051    /// Header names lowercased, values trimmed.
1052    pub headers: Vec<(String, String)>,
1053    pub body: ParsedBody,
1054    /// True when any field had a percent-encoded sequence that decoded to another percent-encoded sequence.
1055    pub double_encoding_detected: bool,
1056    /// Phase 10c: additional inspection-only strings DERIVED from field values that
1057    /// were base64-encoded (gotestwaf Base64Flat). Each entry is the base64-decoded +
1058    /// canonicalized form of some query/cookie/body/header value that passed the
1059    /// base64 candidacy gate. "decode-then-match-then-discard": modules and the
1060    /// prefilter inspect these alongside the real fields, but they are NEVER persisted
1061    /// as a real field value — a derived string that matches no rule has no effect.
1062    pub derived_decoded: Vec<String>,
1063}
1064
1065// ── Scoring audit ───────────────────────────────────────────────────────────
1066
1067/// One recorded contribution to the anomaly score, kept for audit/logging.
1068/// Populated exclusively by the pipeline as it accumulates `ctx.score`.
1069#[derive(Debug, Clone, PartialEq, Eq)]
1070pub struct ScoreContribution {
1071    /// Id of the module that produced the contribution.
1072    pub module: String,
1073    /// Id of the rule that matched.
1074    pub rule_id: String,
1075    /// Severity class, when the contribution came from `Decision::Scores`.
1076    /// `None` for direct `Decision::Score` contributions.
1077    pub severity: Option<Severity>,
1078    /// Points actually added to `ctx.score`.
1079    pub points: u32,
1080}
1081
1082// ── RequestContext ────────────────────────────────────────────────────────────
1083
1084#[derive(Debug)]
1085pub struct RequestContext {
1086    // Identity
1087    pub client_ip: IpAddr,
1088    pub request_id: String,
1089    pub timestamp: SystemTime,
1090    // Request line (raw)
1091    pub method: String,
1092    pub path: String,
1093    pub raw_path: String,
1094    pub query: Option<String>,
1095    pub http_version: String,
1096    // Headers & cookies (raw, as received)
1097    pub headers: Vec<(String, String)>,
1098    pub cookies: Vec<(String, String)>,
1099    // Body bytes (collected before pipeline; streaming deferred to Fase 4+)
1100    pub body: Bytes,
1101    // Canonical forms — populated by the normalizer, read by detection modules
1102    pub normalized: Normalized,
1103    // Anomaly score accumulated across modules
1104    pub score: u32,
1105    // Per-rule breakdown of how `score` was reached (filled by the pipeline)
1106    pub score_contributions: Vec<ScoreContribution>,
1107}
1108
1109// ── tests ─────────────────────────────────────────────────────────────────────
1110
1111#[cfg(test)]
1112mod config_validation_tests {
1113    use super::*;
1114
1115    fn valid() -> Config {
1116        Config {
1117            proxy: ProxyConfig {
1118                listen: "127.0.0.1:8080".parse().unwrap(),
1119                backend: "http://localhost:3000".to_string(),
1120            },
1121            waf: WafConfig {
1122                mode: WafMode::Blocking,
1123                block_threshold: 5,
1124                paranoia_level: 2,
1125                severity_scores: SeverityScores::default(),
1126            },
1127            limits: LimitsConfig::default(),
1128            modules: ModulesConfig::default(),
1129            rate_limit: RateLimitConfig::default(),
1130            network: NetworkConfig::default(),
1131            resilience: ResilienceConfig::default(),
1132            tls: TlsConfig::default(),
1133            metrics: MetricsConfig::default(),
1134        }
1135    }
1136
1137    #[test]
1138    fn valid_config_passes() {
1139        assert!(valid().validate().is_ok());
1140    }
1141
1142    #[test]
1143    fn paranoia_4_is_legal_forward_compatible() {
1144        let mut c = valid();
1145        c.waf.paranoia_level = MAX_PARANOIA_LEVEL; // 4: empty-but-legal
1146        assert!(c.validate().is_ok());
1147    }
1148
1149    #[test]
1150    fn paranoia_out_of_range_rejected() {
1151        let mut c = valid();
1152        c.waf.paranoia_level = 0;
1153        assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(0)));
1154        c.waf.paranoia_level = 5;
1155        assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(5)));
1156    }
1157
1158    #[test]
1159    fn block_threshold_zero_rejected() {
1160        let mut c = valid();
1161        c.waf.block_threshold = 0;
1162        assert_eq!(c.validate(), Err(ConfigError::BlockThresholdZero));
1163    }
1164
1165    #[test]
1166    fn grpc_disabled_ignores_caps() {
1167        // Default grpc is off → caps not validated (cleartext/no-gRPC deploy).
1168        assert!(valid().validate().is_ok());
1169    }
1170
1171    #[test]
1172    fn grpc_enabled_rejects_zero_caps() {
1173        let mut c = valid();
1174        c.modules.grpc.enabled = true;
1175        assert!(c.validate().is_ok()); // defaults are all >= 1
1176        c.modules.grpc.max_depth = 0;
1177        assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_depth")));
1178        c.modules.grpc.max_depth = 16;
1179        c.modules.grpc.max_message_bytes = 0;
1180        assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_message_bytes")));
1181    }
1182
1183    #[test]
1184    fn tls_disabled_ignores_empty_paths() {
1185        // Default TLS is off → empty paths must not trip validation (cleartext deploy).
1186        assert!(valid().validate().is_ok());
1187    }
1188
1189    #[test]
1190    fn tls_enabled_requires_cert_and_key_paths() {
1191        let mut c = valid();
1192        c.tls.enabled = true;
1193        assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("cert_path")));
1194        c.tls.cert_path = "cert.pem".to_string();
1195        assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("key_path")));
1196        c.tls.key_path = "key.pem".to_string();
1197        assert!(c.validate().is_ok());
1198    }
1199
1200    #[test]
1201    fn tls_enabled_rejects_empty_alpn() {
1202        let mut c = valid();
1203        c.tls.enabled = true;
1204        c.tls.cert_path = "cert.pem".to_string();
1205        c.tls.key_path = "key.pem".to_string();
1206        c.tls.alpn = vec![];
1207        assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1208        c.tls.alpn = vec!["h2".to_string(), "  ".to_string()];
1209        assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1210    }
1211
1212    #[test]
1213    fn crs_enabled_requires_files() {
1214        let mut c = valid();
1215        c.modules.crs.enabled = true;
1216        assert_eq!(c.validate(), Err(ConfigError::CrsNoFiles));
1217        c.modules.crs.files = vec!["rules.conf".to_string()];
1218        assert!(c.validate().is_ok());
1219    }
1220
1221    #[test]
1222    fn crs_disabled_ignores_empty_files() {
1223        // Default CRS off → empty file list must not trip validation.
1224        assert!(valid().validate().is_ok());
1225    }
1226
1227    #[test]
1228    fn wasm_enabled_requires_plugins_with_paths() {
1229        let mut c = valid();
1230        c.modules.wasm.enabled = true;
1231        assert_eq!(c.validate(), Err(ConfigError::WasmNoPlugins));
1232        c.modules.wasm.plugins = vec![WasmPluginConfig { path: "  ".into(), config: None }];
1233        assert_eq!(c.validate(), Err(ConfigError::WasmPluginPathEmpty));
1234        c.modules.wasm.plugins = vec![WasmPluginConfig { path: "f.wasm".into(), config: None }];
1235        assert!(c.validate().is_ok());
1236    }
1237
1238    #[test]
1239    fn wasm_disabled_ignores_empty_plugins() {
1240        // Default WASM off → empty plugin list must not trip validation.
1241        assert!(valid().validate().is_ok());
1242    }
1243
1244    #[test]
1245    fn zero_severity_weight_rejected() {
1246        let mut c = valid();
1247        c.waf.severity_scores.warning = 0;
1248        assert_eq!(c.validate(), Err(ConfigError::SeverityWeightZero("warning")));
1249    }
1250
1251    #[test]
1252    fn zero_limit_rejected() {
1253        let mut c = valid();
1254        c.limits.max_json_depth = 0;
1255        assert_eq!(c.validate(), Err(ConfigError::LimitZero("max_json_depth")));
1256    }
1257
1258    #[test]
1259    fn invalid_backend_rejected() {
1260        let mut c = valid();
1261        c.proxy.backend = "localhost:3000".to_string(); // no scheme
1262        assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1263        c.proxy.backend = "http://".to_string(); // no authority
1264        assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1265    }
1266
1267    #[test]
1268    fn rate_limit_values_checked_only_when_enabled() {
1269        let mut c = valid();
1270        // Disabled: bad values are tolerated (dead config), not a startup error.
1271        c.rate_limit.enabled = false;
1272        c.rate_limit.requests = 0;
1273        assert!(c.validate().is_ok());
1274        // Enabled: the same bad value is rejected.
1275        c.rate_limit.enabled = true;
1276        assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("requests")));
1277    }
1278
1279    #[test]
1280    fn rate_limit_score_action_requires_positive_score() {
1281        let mut c = valid();
1282        c.rate_limit.enabled = true;
1283        c.rate_limit.action = RateLimitAction::Score;
1284        c.rate_limit.score = 0;
1285        assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("score")));
1286    }
1287
1288    #[test]
1289    fn trusted_hops_out_of_range_rejected() {
1290        let mut c = valid();
1291        c.network.trusted_hops = 0;
1292        assert_eq!(c.validate(), Err(ConfigError::TrustedHopsOutOfRange(0)));
1293        c.network.trusted_hops = MAX_TRUSTED_HOPS + 1;
1294        assert_eq!(
1295            c.validate(),
1296            Err(ConfigError::TrustedHopsOutOfRange(MAX_TRUSTED_HOPS + 1))
1297        );
1298    }
1299
1300    #[test]
1301    fn invalid_cidr_in_trusted_proxies_rejected() {
1302        let mut c = valid();
1303        c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "999.0.0.0/8".to_string()];
1304        assert_eq!(
1305            c.validate(),
1306            Err(ConfigError::InvalidCidr("999.0.0.0/8".to_string()))
1307        );
1308    }
1309
1310    #[test]
1311    fn valid_cidrs_pass() {
1312        let mut c = valid();
1313        c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "::1".to_string()];
1314        assert!(c.validate().is_ok());
1315    }
1316
1317    #[test]
1318    fn empty_client_ip_header_rejected() {
1319        let mut c = valid();
1320        c.network.client_ip_header = "   ".to_string();
1321        assert_eq!(c.validate(), Err(ConfigError::EmptyClientIpHeader));
1322    }
1323
1324    #[test]
1325    fn zero_upstream_timeout_rejected() {
1326        let mut c = valid();
1327        c.resilience.upstream_timeout_ms = 0;
1328        assert_eq!(c.validate(), Err(ConfigError::ResilienceTimeoutZero));
1329    }
1330
1331    #[test]
1332    fn resilience_defaults_match_documented_posture() {
1333        let r = ResilienceConfig::default();
1334        assert_eq!(r.on_internal_error, FailMode::FailOpen);
1335        assert_eq!(r.on_upstream_error, FailMode::FailClosed);
1336        assert_eq!(r.on_config_error, FailMode::FailOpen);
1337        assert_eq!(r.on_parser_limit, FailMode::FailClosed);
1338    }
1339}