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, 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// ── 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///
62/// `Serialize`/`Deserialize` (lowercase) so the severity travels verbatim in the enriched
63/// decision-log (`ScoreContribution`) and can be reconstructed by a downstream consumer
64/// (e.g. the enterprise control-plane drill-down) from the same type — the field name/format
65/// is the single-sourced contract.
66#[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    /// Called once at startup; compile/init rules here, never in `inspect`.
88    fn init(&mut self, cfg: &Config);
89    /// Read-only access to context; pipeline owns mutation of `score`.
90    fn inspect(&self, ctx: &RequestContext) -> Decision;
91    /// `true` for a STRUCTURAL inspection module (e.g. GraphQL) whose decision does
92    /// NOT come from a content-rule match. The content fast-path (Pillar 3) may prove
93    /// "no content rule can match" and skip CONTENT inspection — but it cannot prove
94    /// a structural module is inert, so structural modules run even on the skip path.
95    /// Default `false` (a content module, gated by the fast-path).
96    fn structural(&self) -> bool {
97        false
98    }
99}
100
101// ── Config ────────────────────────────────────────────────────────────────────
102
103// `#[non_exhaustive]`: adding a future top-level section (as `tls` was added) must not
104// break external constructors. Cross-crate code can no longer use a `Config { .. }` literal
105// — it builds from `Config::default()` (or TOML) and mutates — so any new field is absorbed
106// by the default. This protects the whole tree transitively: every sub-config is reached
107// through a default/deserialized `Config`. The few sub-configs ALSO taken by-value in a
108// public fn (`TlsConfig`, `NetworkConfig`, `LimitsConfig`) are marked `#[non_exhaustive]`
109// individually; the rest stay literal-constructible.
110#[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    /// Shared network settings (trusted-proxy client-IP resolution). Reused by
122    /// rate limiting, structured logging and future Geo/IP-reputation.
123    #[serde(default)]
124    pub network: NetworkConfig,
125    /// Per-scenario behaviour when the WAF itself is in trouble.
126    #[serde(default)]
127    pub resilience: ResilienceConfig,
128    /// Inbound TLS termination (Phase 12). Default off → the listener stays cleartext,
129    /// exactly as before. Basic, cert-from-file termination is core (`BOUNDARY.md` §3.2);
130    /// cert management at scale (ACME/rotation/mTLS-PKI) is enterprise.
131    #[serde(default)]
132    pub tls: TlsConfig,
133    /// Prometheus metrics exposition (B1). Default off → no separate listener. Served on a
134    /// dedicated loopback port (never the data port — see ARCHITECTURE §9).
135    #[serde(default)]
136    pub metrics: MetricsConfig,
137}
138
139impl Default for Config {
140    /// A valid, non-disruptive base for programmatic/test construction: detection-only mode
141    /// (detects but never blocks), the standard detection modules ON at their defaults
142    /// (GraphQL/gRPC off — opt-in), rate limiting off, cleartext, metrics off. Production
143    /// always supplies `[proxy]`/`[waf]` from TOML; this default exists so external code never
144    /// needs a `Config { .. }` literal (forbidden by `#[non_exhaustive]`).
145    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// ── Metrics (B1: Prometheus exposition) ────────────────────────────────────────
161
162/// Prometheus `/metrics` exposition. **OPEN baseline** (`BOUNDARY.md` §1.6). Served on a
163/// SEPARATE loopback listener — never the data port, which would let the WAF inspect/expose
164/// its own internal posture (blocked/rate-limited volumes are an info leak if reachable).
165#[derive(Debug, Clone, Deserialize)]
166pub struct MetricsConfig {
167    /// Off by default → no metrics listener is bound.
168    #[serde(default)]
169    pub enabled: bool,
170    /// Where to serve `/metrics`. Default loopback (`127.0.0.1:9090`), never `0.0.0.0`.
171    #[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// ── TLS termination (Phase 12) ─────────────────────────────────────────────────
186
187/// Inbound TLS termination configuration. **Basic termination, cert from file** is the
188/// OPEN core surface (`BOUNDARY.md` §3.2): single-node self-sufficiency. ACME/rotation/
189/// multi-node certs / mTLS-with-managed-PKI are ENTERPRISE and plug in behind the
190/// `TlsCertSource` seam (see `waf-proxy::tls`).
191// `#[non_exhaustive]`: `TlsConfig` is taken by-value by public fns (`acceptor_from_config`/
192// `acceptor_from_source` in waf-proxy::tls), so it escapes the transitive protection of a
193// non-exhaustive `Config` — external callers must build it from `TlsConfig::default()`.
194#[derive(Debug, Clone, Deserialize)]
195#[non_exhaustive]
196pub struct TlsConfig {
197    /// Default OFF: the listener serves cleartext (h1 + h2c). When on, the listener
198    /// serves ONLY TLS — there is **no cleartext fallback** on the same port (a required
199    /// TLS that fails to build is a fatal boot error, never a silent downgrade).
200    #[serde(default)]
201    pub enabled: bool,
202    /// PEM file with the server certificate chain (leaf first).
203    #[serde(default)]
204    pub cert_path: String,
205    /// PEM file with the private key (PKCS#8 / PKCS#1 / SEC1).
206    #[serde(default)]
207    pub key_path: String,
208    /// ALPN protocols advertised, in preference order. Default `["h2","http/1.1"]` so an
209    /// h2-capable client (e.g. gRPC-over-TLS, Phase 13) negotiates HTTP/2 while an
210    /// h1-only client falls back cleanly.
211    #[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// ── Resilience (fail-open / fail-closed, per scenario) ─────────────────────────
231
232/// What to do when a given failure scenario occurs. Uniform across scenarios for
233/// schema consistency, but the *meaning* of `FailOpen` is scenario-specific — see
234/// `ResilienceConfig` and ARCHITECTURE §9.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum FailMode {
238    /// Favour availability.
239    FailOpen,
240    /// Favour security / surface the error.
241    FailClosed,
242}
243
244/// Explicit, per-scenario failure policy. No single global boolean: each kind of
245/// trouble has its own correct posture (see the defaults' rationale in §9).
246#[derive(Debug, Clone, Copy, Deserialize)]
247pub struct ResilienceConfig {
248    /// Origin unreachable / timeout. `fail_closed` → 502, `fail_open` → 503
249    /// (retryable). NB: `fail_open` here does NOT mean "let traffic through"
250    /// (there is no origin to reach) — only which 5xx is returned.
251    #[serde(default = "default_on_upstream_error")]
252    pub on_upstream_error: FailMode,
253    /// Module panic / regex blow-up. `fail_open` → skip the module and continue
254    /// (a WAF bug must not take down the site); `fail_closed` → synthetic block.
255    #[serde(default = "default_on_internal_error")]
256    pub on_internal_error: FailMode,
257    /// Invalid config detected at runtime (hot reload, Pillar 3). `fail_open` →
258    /// keep last-good config; `fail_closed` → refuse serving until valid.
259    #[serde(default = "default_on_config_error")]
260    pub on_config_error: FailMode,
261    /// Normalization failed (limits exceeded / malformed input). `fail_closed` →
262    /// 400; `fail_open` → forward UNINSPECTED (logged loudly).
263    #[serde(default = "default_on_parser_limit")]
264    pub on_parser_limit: FailMode,
265    /// Hard cap on the upstream round-trip so a stalled origin cannot pin the
266    /// worker. Must be >= 1 (validated).
267    #[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    /// Upstream round-trip cap as a `Duration`.
291    pub fn upstream_timeout(&self) -> std::time::Duration {
292        std::time::Duration::from_millis(self.upstream_timeout_ms)
293    }
294}
295
296// ── Network (shared client-IP resolution) ─────────────────────────────────────
297
298/// Trusted-proxy configuration for resolving the real client IP behind an
299/// LB/CDN/TLS-terminator. See `network::ClientIpResolver` for the logic.
300// `#[non_exhaustive]`: taken by-value by the public `ClientIpResolver::from_config`.
301#[derive(Debug, Clone, Deserialize)]
302#[non_exhaustive]
303pub struct NetworkConfig {
304    /// CIDR blocks of YOUR proxies (IPv4 or IPv6). Empty (default) = fail-safe:
305    /// the forwarded header is ALWAYS ignored and the peer address is used.
306    #[serde(default)]
307    pub trusted_proxies: Vec<String>,
308    /// Header carrying the forwarded chain (default `x-forwarded-for`).
309    #[serde(default = "default_client_ip_header")]
310    pub client_ip_header: String,
311    /// How many hops, counted from the RIGHT of the chain, to trust. Never the
312    /// leftmost IP (that one is client-controlled and spoofable).
313    #[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
330// ── Semantic validation (reusable by startup load AND hot reload) ──────────────
331
332/// Highest paranoia level the contract allows. The validator guards the legal
333/// space of the CONTRACT (see ARCHITECTURE §7), not the current rule set — PL4 is
334/// forward-compatible even if no rule uses it yet.
335pub const MAX_PARANOIA_LEVEL: u8 = 4;
336/// Upper bound on `trusted_hops`: real proxy chains are short; a huge value is a
337/// configuration mistake (and would make every request fall back to the peer).
338pub const MAX_TRUSTED_HOPS: usize = 10;
339
340/// A semantic configuration error. Distinct from TOML *syntax* errors (handled at
341/// parse time) and from I/O errors (file missing/unreadable).
342#[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    /// Semantic validation, separate from TOML parsing. Called at startup (after
410    /// parse, before build) and reused by hot reload. Fails fast with a specific
411    /// error rather than starting with values that look valid but aren't.
412    pub fn validate(&self) -> Result<(), ConfigError> {
413        // proxy.backend: absolute http(s) URL with a non-empty authority.
414        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        // waf scoring knobs.
424        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        // Defensive limits: a 0 breaks parsing/inspection.
443        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        // Rate limiting: only meaningful when enabled.
458        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        // Network / client-IP resolution.
478        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        // Resilience.
491        if self.resilience.upstream_timeout_ms == 0 {
492            return Err(ConfigError::ResilienceTimeoutZero);
493        }
494
495        // TLS: paths/ALPN are only meaningful when enabled. File existence + cert/key
496        // parsing happen at bind time (I/O, fs-free validate stays reload-safe); a
497        // required-but-unreadable cert is a fatal boot error (see waf-proxy::tls).
498        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        // GraphQL caps: a 0 cap would reject every query → only meaningful when enabled.
511        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        // gRPC caps: a 0 cap would reject every message → only meaningful when enabled.
527        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        // CRS import: a path list is required when enabled (file I/O is deferred to boot,
541        // keeping `validate` fs-free and reload-safe — like TLS).
542        if self.modules.crs.enabled && self.modules.crs.files.is_empty() {
543            return Err(ConfigError::CrsNoFiles);
544        }
545
546        // WASM runtime: at least one plugin when enabled, each with a non-empty path
547        // (file I/O deferred to boot, keeping `validate` fs-free and reload-safe).
548        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// ── Rate limiting (L7, on_connection) ─────────────────────────────────────────
562
563/// Which request attribute the rate limiter buckets on. `client_ip` is the peer
564/// socket address; behind an LB/CDN this collapses to the proxy IP — see §8.
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
566#[serde(rename_all = "snake_case")]
567pub enum RateLimitKey {
568    #[default]
569    ClientIp,
570}
571
572/// What happens when a key exceeds its budget.
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
574#[serde(rename_all = "snake_case")]
575pub enum RateLimitAction {
576    /// Reject immediately with HTTP 429 (`Decision::Reject`).
577    #[default]
578    Block,
579    /// Contribute `score` points to the cumulative anomaly score instead.
580    Score,
581}
582
583#[derive(Debug, Clone, Deserialize)]
584pub struct RateLimitConfig {
585    /// Off when the `[rate_limit]` section is absent (fail-safe default).
586    #[serde(default)]
587    pub enabled: bool,
588    #[serde(default)]
589    pub key: RateLimitKey,
590    /// Token refill budget per window (tokens added per `window_seconds`).
591    #[serde(default = "default_rl_requests")]
592    pub requests: u32,
593    #[serde(default = "default_rl_window")]
594    pub window_seconds: u64,
595    /// Bucket capacity (max burst). Defaults to `requests` when omitted.
596    #[serde(default)]
597    pub burst: Option<u32>,
598    #[serde(default)]
599    pub action: RateLimitAction,
600    /// Points added when `action = "score"` and the budget is exceeded.
601    #[serde(default = "default_rl_score")]
602    pub score: u32,
603    /// Memory cap: when the tracked-key map reaches this size, idle (fully
604    /// refilled) buckets are swept before inserting a new key.
605    #[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    /// HTTP request-smuggling framing checks (CL/TE). Structural security control,
660    /// default on (see ARCHITECTURE §8).
661    #[serde(default)]
662    pub request_smuggling: ModuleConfig,
663    /// GraphQL structural protections (depth / aliases / fields / directives / batch /
664    /// introspection). A structural control like request_smuggling — NOT content-regex.
665    /// Default OFF: it is endpoint-specific and the caps need per-app tuning (Phase 11).
666    #[serde(default)]
667    pub graphql: GraphqlConfig,
668    /// gRPC structural protections (message size / field count / nesting depth) + a
669    /// compressed-payload policy. Structural, like request_smuggling/graphql — the
670    /// CONTENT of protobuf fields is inspected by the normal modules via the §6 derived
671    /// channel. Default OFF: gRPC needs HTTP/2 and the caps are per-app (gRPC phase).
672    #[serde(default)]
673    pub grpc: GrpcConfig,
674    /// Imported OWASP CRS / ModSecurity rules (B2). The core ships the *parser/engine*;
675    /// the curated rule CONTENT is the operator's (or enterprise §2.4). Default OFF.
676    #[serde(default)]
677    pub crs: CrsConfig,
678    /// Proxy-Wasm plugin runtime (B3). The core ships the *runtime*; marketplace/signing is
679    /// enterprise (§2.4). Each plugin is a `.wasm` filter run as a `WafModule`. Default OFF.
680    #[serde(default)]
681    pub wasm: WasmConfig,
682}
683
684/// CRS/ModSecurity rule import (B2). Loads `SecRule …` files at boot and runs the
685/// supported subset as a `WafModule`. The parser is the OPEN baseline (`BOUNDARY.md`
686/// §1.7); rules a file uses that fall outside the v1 subset are skipped and reported at
687/// boot, never silently dropped.
688#[derive(Debug, Clone, Default, Deserialize)]
689pub struct CrsConfig {
690    /// Default OFF (opt-in per deployment).
691    #[serde(default)]
692    pub enabled: bool,
693    /// `seclang` files to import, in include order. Read at boot; an unreadable file is
694    /// logged and skipped (the module still loads the readable ones).
695    #[serde(default)]
696    pub files: Vec<String>,
697}
698
699/// Proxy-Wasm plugin runtime (B3). Loads one or more `.wasm` filters at boot and runs each
700/// as a `WafModule` on every request (`structural`). The runtime is the OPEN baseline
701/// (`BOUNDARY.md` §1.7); a plugin whose `.wasm` cannot be compiled/instantiated is logged
702/// and skipped (fail-open at load, like CRS) while the per-request posture is fail-closed.
703#[derive(Debug, Clone, Deserialize)]
704pub struct WasmConfig {
705    /// Default OFF (opt-in per deployment).
706    #[serde(default)]
707    pub enabled: bool,
708    /// Pre-instantiated guest instances per plugin (the request concurrency a plugin can
709    /// serve before callers queue on checkout).
710    #[serde(default = "default_wasm_pool_size")]
711    pub pool_size: usize,
712    /// Per-request fuel budget — the plugin's CPU/latency ceiling (it runs on every
713    /// request). Exhaustion traps and fails closed.
714    #[serde(default = "default_wasm_fuel")]
715    pub fuel_per_request: u64,
716    /// Per-instance linear-memory cap (bytes). Growth beyond it is denied → fail closed.
717    #[serde(default = "default_wasm_max_memory")]
718    pub max_memory_bytes: usize,
719    /// How long a request waits for a free instance before failing closed (pool exhaustion).
720    #[serde(default = "default_wasm_checkout_timeout_ms")]
721    pub checkout_timeout_ms: u64,
722    /// The plugins to load, in order.
723    #[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/// One Proxy-Wasm plugin: a `.wasm` path plus an opaque configuration string passed to the
746/// guest's `proxy_on_configure`.
747#[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/// GraphQL module configuration (Phase 11). Structural DoS/abuse caps applied to the
769/// GraphQL operation(s) carried by a request (JSON `query` field, `application/graphql`
770/// raw body, or GET `?query=`). All counts come from the lexical [`graphql_lex`] pass.
771#[derive(Debug, Clone, Deserialize)]
772pub struct GraphqlConfig {
773    /// Default OFF (opt-in per deployment).
774    #[serde(default)]
775    pub enabled: bool,
776    /// Request paths treated as GraphQL endpoints (exact, case-insensitive). JSON and
777    /// GET transports are inspected ONLY on these paths (so a non-GraphQL JSON API with
778    /// a `query` field is not affected); `application/graphql` is recognized by its
779    /// Content-Type regardless of path.
780    #[serde(default = "default_graphql_paths")]
781    pub paths: Vec<String>,
782    /// Max selection-set nesting depth (paren-aware).
783    #[serde(default = "default_graphql_max_depth")]
784    pub max_depth: u32,
785    /// Max alias count (`alias: field`) — the "alias bomb" cap.
786    #[serde(default = "default_graphql_max_aliases")]
787    pub max_aliases: u32,
788    /// Max selection-name count (a cheap complexity proxy).
789    #[serde(default = "default_graphql_max_fields")]
790    pub max_fields: u32,
791    /// Max `@directive` count.
792    #[serde(default = "default_graphql_max_directives")]
793    pub max_directives: u32,
794    /// Max number of operations in one (batched) request.
795    #[serde(default = "default_graphql_max_batch")]
796    pub max_batch: u32,
797    /// Block schema introspection (`__schema`/`__type`) → 403. Default off (policy).
798    #[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/// What to do with a gRPC message whose payload is COMPRESSED (per-message flag set or a
825/// non-identity `grpc-encoding`): its bytes are opaque to the WAF, so it cannot be
826/// inspected. `Reject` (default) is fail-closed — a `gzip` payload you let through is a
827/// trivial bypass (compress the attack, skip the WAF). `Passthrough` forwards it
828/// UNINSPECTED — a deliberate, on-record choice for a backend that requires compression.
829#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
830#[serde(rename_all = "snake_case")]
831pub enum CompressedPolicy {
832    Reject,
833    Passthrough,
834}
835
836/// gRPC module configuration (gRPC phase). Structural DoS/abuse caps on the framed
837/// protobuf body (message size / field count / nesting depth) + a compressed-payload
838/// policy. Structural only — protobuf field CONTENT flows to the normal content modules
839/// via the §6 derived channel, not through this module. Counts come from the
840/// [`grpc_extract`](../waf_normalizer/grpc/fn.grpc_extract.html) pass.
841#[derive(Debug, Clone, Deserialize)]
842pub struct GrpcConfig {
843    /// Default OFF (opt-in per deployment; needs HTTP/2).
844    #[serde(default)]
845    pub enabled: bool,
846    /// Max total inspectable payload bytes across the framed messages in one request.
847    #[serde(default = "default_grpc_max_message_bytes")]
848    pub max_message_bytes: u64,
849    /// Max protobuf field count (field-bomb cap).
850    #[serde(default = "default_grpc_max_fields")]
851    pub max_fields: u32,
852    /// Max sub-message nesting depth (depth-bomb cap).
853    #[serde(default = "default_grpc_max_depth")]
854    pub max_depth: u32,
855    /// What to do with a compressed (un-inspectable) payload. Default `reject` (fail-closed).
856    #[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/// Points assigned to each severity class. Replaces per-module hardcoded scores;
878/// changing these values directly changes how much each match contributes.
879#[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
891// Critical raised 5 → 6 (Fase 7 / Pilastro 2, config C2): a single high-confidence
892// rule blocks with own-merit margin >= 1 over the default block_threshold (5), while
893// Warning/Notice stay sub-threshold (accumulation-only). Validated on the corpus by
894// waf-corpus `tests/validation.rs` (RECOMMENDED_SEVERITY); rationale in ARCHITECTURE §7.
895fn 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    /// Resolve a severity class to its configured point weight.
913    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    // NOTE: the old `fail_open: bool` was removed in favour of the per-scenario
933    // `[resilience]` section. A leftover `waf.fail_open` in TOML is rejected with
934    // a migration hint at load time (see waf-proxy::config), never silently.
935    #[serde(default = "default_block_threshold")]
936    pub block_threshold: u32,
937    /// Paranoia level (1..=4). Higher levels activate more (and noisier) rules.
938    #[serde(default = "default_paranoia_level")]
939    pub paranoia_level: u8,
940    /// Point weight per severity class. Used by the pipeline to score matches.
941    #[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    /// Loopback placeholder for programmatic/test construction; production supplies
955    /// `[proxy]` from TOML.
956    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    /// Detection-only, default thresholds — a non-disruptive base (never blocks until
966    /// explicitly switched to `Blocking`).
967    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// `#[non_exhaustive]`: taken by-value by the public `Normalizer::new(&LimitsConfig)`.
985#[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 } // 1 MiB
1003fn default_max_header_size() -> usize { 8_192 }   // 8 KiB
1004fn 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// ── Parsed / Normalized types ─────────────────────────────────────────────────
1023
1024/// Parsed body, populated by the normalizer based on Content-Type.
1025#[derive(Debug, Clone, Default)]
1026pub enum ParsedBody {
1027    #[default]
1028    None,
1029    FormUrlEncoded(Vec<(String, String)>),
1030    Multipart(Vec<MultipartField>),
1031    /// JSON flattened to (dot-path, string-value) pairs for pattern inspection.
1032    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/// Canonicalized version of all inspectable fields.
1045/// Populated by the normalizer before any detection module runs.
1046/// Raw originals remain in RequestContext fields.
1047#[derive(Debug, Clone, Default)]
1048pub struct Normalized {
1049    /// URL-decoded, traversal-resolved, lowercased path.
1050    pub path: String,
1051    /// URL-decoded query string (raw, single decode).
1052    pub query: Option<String>,
1053    /// Parsed, decoded query parameters — repeated names are all kept.
1054    pub query_params: Vec<(String, String)>,
1055    /// Parsed cookies (from Cookie headers).
1056    pub cookies: Vec<(String, String)>,
1057    /// Header names lowercased, values trimmed.
1058    pub headers: Vec<(String, String)>,
1059    pub body: ParsedBody,
1060    /// True when any field had a percent-encoded sequence that decoded to another percent-encoded sequence.
1061    pub double_encoding_detected: bool,
1062    /// Phase 10c: additional inspection-only strings DERIVED from field values that
1063    /// were base64-encoded (gotestwaf Base64Flat). Each entry is the base64-decoded +
1064    /// canonicalized form of some query/cookie/body/header value that passed the
1065    /// base64 candidacy gate. "decode-then-match-then-discard": modules and the
1066    /// prefilter inspect these alongside the real fields, but they are NEVER persisted
1067    /// as a real field value — a derived string that matches no rule has no effect.
1068    pub derived_decoded: Vec<String>,
1069}
1070
1071// ── Scoring audit ───────────────────────────────────────────────────────────
1072
1073/// One recorded contribution to the anomaly score, kept for audit/logging.
1074/// Populated exclusively by the pipeline as it accumulates `ctx.score`.
1075///
1076/// `Serialize`/`Deserialize` so the per-rule breakdown can be emitted in the decision-log
1077/// (the core logs the array on a denied request) and reconstructed downstream — this is the
1078/// data the enterprise control-plane drill-down (§7) rebuilds the verdict from.
1079#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1080pub struct ScoreContribution {
1081    /// Id of the module that produced the contribution.
1082    pub module: String,
1083    /// Id of the rule that matched.
1084    pub rule_id: String,
1085    /// Severity class, when the contribution came from `Decision::Scores`.
1086    /// `None` for direct `Decision::Score` contributions.
1087    pub severity: Option<Severity>,
1088    /// Points actually added to `ctx.score`.
1089    pub points: u32,
1090}
1091
1092#[cfg(test)]
1093mod scoring_serde_tests {
1094    use super::*;
1095
1096    /// Locks the decision-log `score_contributions` JSON shape — the §7 control-plane
1097    /// drill-down parses exactly this. Field names are snake_case; severity is lowercase;
1098    /// a `Score`-derived contribution has `severity: null`.
1099    #[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        // Round-trips back into the same typed model the control plane will reuse.
1121        let back: Vec<ScoreContribution> = serde_json::from_str(&json).unwrap();
1122        assert_eq!(back, items);
1123    }
1124}
1125
1126// ── RequestContext ────────────────────────────────────────────────────────────
1127
1128#[derive(Debug)]
1129pub struct RequestContext {
1130    // Identity
1131    pub client_ip: IpAddr,
1132    pub request_id: String,
1133    pub timestamp: SystemTime,
1134    // Request line (raw)
1135    pub method: String,
1136    pub path: String,
1137    pub raw_path: String,
1138    pub query: Option<String>,
1139    pub http_version: String,
1140    // Headers & cookies (raw, as received)
1141    pub headers: Vec<(String, String)>,
1142    pub cookies: Vec<(String, String)>,
1143    // Body bytes (collected before pipeline; streaming deferred to Fase 4+)
1144    pub body: Bytes,
1145    // Canonical forms — populated by the normalizer, read by detection modules
1146    pub normalized: Normalized,
1147    // Anomaly score accumulated across modules
1148    pub score: u32,
1149    // Per-rule breakdown of how `score` was reached (filled by the pipeline)
1150    pub score_contributions: Vec<ScoreContribution>,
1151}
1152
1153// ── tests ─────────────────────────────────────────────────────────────────────
1154
1155#[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; // 4: empty-but-legal
1190        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        // Default grpc is off → caps not validated (cleartext/no-gRPC deploy).
1212        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()); // defaults are all >= 1
1220        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        // Default TLS is off → empty paths must not trip validation (cleartext deploy).
1230        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        // Default CRS off → empty file list must not trip validation.
1268        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        // Default WASM off → empty plugin list must not trip validation.
1285        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(); // no scheme
1306        assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1307        c.proxy.backend = "http://".to_string(); // no authority
1308        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        // Disabled: bad values are tolerated (dead config), not a startup error.
1315        c.rate_limit.enabled = false;
1316        c.rate_limit.requests = 0;
1317        assert!(c.validate().is_ok());
1318        // Enabled: the same bad value is rejected.
1319        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}