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    /// Structural evasion signals (F-1): cabling the normalizer's `null_byte_detected`
660    /// (path NUL stripped pre-inspection) and `double_encoding_detected` flags into
661    /// scored contributions so a neutralized evasion still leaves a trace. NOT a
662    /// content-regex module. Default ON (a core security control like request_smuggling).
663    #[serde(default)]
664    pub evasion: ModuleConfig,
665    /// HTTP request-smuggling framing checks (CL/TE). Structural security control,
666    /// default on (see ARCHITECTURE §8).
667    #[serde(default)]
668    pub request_smuggling: ModuleConfig,
669    /// GraphQL structural protections (depth / aliases / fields / directives / batch /
670    /// introspection). A structural control like request_smuggling — NOT content-regex.
671    /// Default OFF: it is endpoint-specific and the caps need per-app tuning (Phase 11).
672    #[serde(default)]
673    pub graphql: GraphqlConfig,
674    /// gRPC structural protections (message size / field count / nesting depth) + a
675    /// compressed-payload policy. Structural, like request_smuggling/graphql — the
676    /// CONTENT of protobuf fields is inspected by the normal modules via the §6 derived
677    /// channel. Default OFF: gRPC needs HTTP/2 and the caps are per-app (gRPC phase).
678    #[serde(default)]
679    pub grpc: GrpcConfig,
680    /// Imported OWASP CRS / ModSecurity rules (B2). The core ships the *parser/engine*;
681    /// the curated rule CONTENT is the operator's (or enterprise §2.4). Default OFF.
682    #[serde(default)]
683    pub crs: CrsConfig,
684    /// Proxy-Wasm plugin runtime (B3). The core ships the *runtime*; marketplace/signing is
685    /// enterprise (§2.4). Each plugin is a `.wasm` filter run as a `WafModule`. Default OFF.
686    #[serde(default)]
687    pub wasm: WasmConfig,
688}
689
690/// CRS/ModSecurity rule import (B2). Loads `SecRule …` files at boot and runs the
691/// supported subset as a `WafModule`. The parser is the OPEN baseline (`BOUNDARY.md`
692/// §1.7); rules a file uses that fall outside the v1 subset are skipped and reported at
693/// boot, never silently dropped.
694#[derive(Debug, Clone, Default, Deserialize)]
695pub struct CrsConfig {
696    /// Default OFF (opt-in per deployment).
697    #[serde(default)]
698    pub enabled: bool,
699    /// `seclang` files to import, in include order. Read at boot; an unreadable file is
700    /// logged and skipped (the module still loads the readable ones).
701    #[serde(default)]
702    pub files: Vec<String>,
703}
704
705/// Proxy-Wasm plugin runtime (B3). Loads one or more `.wasm` filters at boot and runs each
706/// as a `WafModule` on every request (`structural`). The runtime is the OPEN baseline
707/// (`BOUNDARY.md` §1.7); a plugin whose `.wasm` cannot be compiled/instantiated is logged
708/// and skipped (fail-open at load, like CRS) while the per-request posture is fail-closed.
709#[derive(Debug, Clone, Deserialize)]
710pub struct WasmConfig {
711    /// Default OFF (opt-in per deployment).
712    #[serde(default)]
713    pub enabled: bool,
714    /// Pre-instantiated guest instances per plugin (the request concurrency a plugin can
715    /// serve before callers queue on checkout).
716    #[serde(default = "default_wasm_pool_size")]
717    pub pool_size: usize,
718    /// Per-request fuel budget — the plugin's CPU/latency ceiling (it runs on every
719    /// request). Exhaustion traps and fails closed.
720    #[serde(default = "default_wasm_fuel")]
721    pub fuel_per_request: u64,
722    /// Per-instance linear-memory cap (bytes). Growth beyond it is denied → fail closed.
723    #[serde(default = "default_wasm_max_memory")]
724    pub max_memory_bytes: usize,
725    /// How long a request waits for a free instance before failing closed (pool exhaustion).
726    #[serde(default = "default_wasm_checkout_timeout_ms")]
727    pub checkout_timeout_ms: u64,
728    /// The plugins to load, in order.
729    #[serde(default)]
730    pub plugins: Vec<WasmPluginConfig>,
731}
732
733fn default_wasm_pool_size() -> usize { 4 }
734fn default_wasm_fuel() -> u64 { 10_000_000 }
735fn default_wasm_max_memory() -> usize { 16 * 1024 * 1024 }
736fn default_wasm_checkout_timeout_ms() -> u64 { 100 }
737
738impl Default for WasmConfig {
739    fn default() -> Self {
740        WasmConfig {
741            enabled: false,
742            pool_size: default_wasm_pool_size(),
743            fuel_per_request: default_wasm_fuel(),
744            max_memory_bytes: default_wasm_max_memory(),
745            checkout_timeout_ms: default_wasm_checkout_timeout_ms(),
746            plugins: Vec::new(),
747        }
748    }
749}
750
751/// One Proxy-Wasm plugin: a `.wasm` path plus an opaque configuration string passed to the
752/// guest's `proxy_on_configure`.
753#[derive(Debug, Clone, Default, Deserialize)]
754pub struct WasmPluginConfig {
755    pub path: String,
756    #[serde(default)]
757    pub config: Option<String>,
758}
759
760#[derive(Debug, Clone, Deserialize)]
761pub struct ModuleConfig {
762    #[serde(default = "default_true")]
763    pub enabled: bool,
764}
765
766fn default_true() -> bool { true }
767
768impl Default for ModuleConfig {
769    fn default() -> Self {
770        Self { enabled: true }
771    }
772}
773
774/// GraphQL module configuration (Phase 11). Structural DoS/abuse caps applied to the
775/// GraphQL operation(s) carried by a request (JSON `query` field, `application/graphql`
776/// raw body, or GET `?query=`). All counts come from the lexical `graphql_lex` pass.
777#[derive(Debug, Clone, Deserialize)]
778pub struct GraphqlConfig {
779    /// Default OFF (opt-in per deployment).
780    #[serde(default)]
781    pub enabled: bool,
782    /// Request paths treated as GraphQL endpoints (exact, case-insensitive). JSON and
783    /// GET transports are inspected ONLY on these paths (so a non-GraphQL JSON API with
784    /// a `query` field is not affected); `application/graphql` is recognized by its
785    /// Content-Type regardless of path.
786    #[serde(default = "default_graphql_paths")]
787    pub paths: Vec<String>,
788    /// Max selection-set nesting depth (paren-aware).
789    #[serde(default = "default_graphql_max_depth")]
790    pub max_depth: u32,
791    /// Max alias count (`alias: field`) — the "alias bomb" cap.
792    #[serde(default = "default_graphql_max_aliases")]
793    pub max_aliases: u32,
794    /// Max selection-name count (a cheap complexity proxy).
795    #[serde(default = "default_graphql_max_fields")]
796    pub max_fields: u32,
797    /// Max `@directive` count.
798    #[serde(default = "default_graphql_max_directives")]
799    pub max_directives: u32,
800    /// Max number of operations in one (batched) request.
801    #[serde(default = "default_graphql_max_batch")]
802    pub max_batch: u32,
803    /// Block schema introspection (`__schema`/`__type`) → 403. Default off (policy).
804    #[serde(default)]
805    pub block_introspection: bool,
806}
807
808fn default_graphql_paths() -> Vec<String> { vec!["/graphql".to_string()] }
809fn default_graphql_max_depth() -> u32 { 15 }
810fn default_graphql_max_aliases() -> u32 { 30 }
811fn default_graphql_max_fields() -> u32 { 1000 }
812fn default_graphql_max_directives() -> u32 { 50 }
813fn default_graphql_max_batch() -> u32 { 10 }
814
815impl Default for GraphqlConfig {
816    fn default() -> Self {
817        Self {
818            enabled: false,
819            paths: default_graphql_paths(),
820            max_depth: default_graphql_max_depth(),
821            max_aliases: default_graphql_max_aliases(),
822            max_fields: default_graphql_max_fields(),
823            max_directives: default_graphql_max_directives(),
824            max_batch: default_graphql_max_batch(),
825            block_introspection: false,
826        }
827    }
828}
829
830/// What to do with a gRPC message whose payload is COMPRESSED (per-message flag set or a
831/// non-identity `grpc-encoding`): its bytes are opaque to the WAF, so it cannot be
832/// inspected. `Reject` (default) is fail-closed — a `gzip` payload you let through is a
833/// trivial bypass (compress the attack, skip the WAF). `Passthrough` forwards it
834/// UNINSPECTED — a deliberate, on-record choice for a backend that requires compression.
835#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
836#[serde(rename_all = "snake_case")]
837pub enum CompressedPolicy {
838    Reject,
839    Passthrough,
840}
841
842/// gRPC module configuration (gRPC phase). Structural DoS/abuse caps on the framed
843/// protobuf body (message size / field count / nesting depth) + a compressed-payload
844/// policy. Structural only — protobuf field CONTENT flows to the normal content modules
845/// via the §6 derived channel, not through this module. Counts come from the
846/// [`grpc_extract`](../waf_normalizer/grpc/fn.grpc_extract.html) pass.
847#[derive(Debug, Clone, Deserialize)]
848pub struct GrpcConfig {
849    /// Default OFF (opt-in per deployment; needs HTTP/2).
850    #[serde(default)]
851    pub enabled: bool,
852    /// Max total inspectable payload bytes across the framed messages in one request.
853    #[serde(default = "default_grpc_max_message_bytes")]
854    pub max_message_bytes: u64,
855    /// Max protobuf field count (field-bomb cap).
856    #[serde(default = "default_grpc_max_fields")]
857    pub max_fields: u32,
858    /// Max sub-message nesting depth (depth-bomb cap).
859    #[serde(default = "default_grpc_max_depth")]
860    pub max_depth: u32,
861    /// What to do with a compressed (un-inspectable) payload. Default `reject` (fail-closed).
862    #[serde(default = "default_grpc_on_compressed")]
863    pub on_compressed: CompressedPolicy,
864}
865
866fn default_grpc_max_message_bytes() -> u64 { 4 * 1024 * 1024 }
867fn default_grpc_max_fields() -> u32 { 4096 }
868fn default_grpc_max_depth() -> u32 { 16 }
869fn default_grpc_on_compressed() -> CompressedPolicy { CompressedPolicy::Reject }
870
871impl Default for GrpcConfig {
872    fn default() -> Self {
873        Self {
874            enabled: false,
875            max_message_bytes: default_grpc_max_message_bytes(),
876            max_fields: default_grpc_max_fields(),
877            max_depth: default_grpc_max_depth(),
878            on_compressed: default_grpc_on_compressed(),
879        }
880    }
881}
882
883/// Points assigned to each severity class. Replaces per-module hardcoded scores;
884/// changing these values directly changes how much each match contributes.
885#[derive(Debug, Clone, Copy, Deserialize)]
886pub struct SeverityScores {
887    #[serde(default = "default_critical_score")]
888    pub critical: u32,
889    #[serde(default = "default_error_score")]
890    pub error: u32,
891    #[serde(default = "default_warning_score")]
892    pub warning: u32,
893    #[serde(default = "default_notice_score")]
894    pub notice: u32,
895}
896
897// Critical raised 5 → 6 (Fase 7 / Pilastro 2, config C2): a single high-confidence
898// rule blocks with own-merit margin >= 1 over the default block_threshold (5), while
899// Warning/Notice stay sub-threshold (accumulation-only). Validated on the corpus by
900// waf-corpus `tests/validation.rs` (RECOMMENDED_SEVERITY); rationale in ARCHITECTURE §7.
901fn default_critical_score() -> u32 { 6 }
902fn default_error_score() -> u32 { 4 }
903fn default_warning_score() -> u32 { 3 }
904fn default_notice_score() -> u32 { 2 }
905
906impl Default for SeverityScores {
907    fn default() -> Self {
908        Self {
909            critical: default_critical_score(),
910            error: default_error_score(),
911            warning: default_warning_score(),
912            notice: default_notice_score(),
913        }
914    }
915}
916
917impl SeverityScores {
918    /// Resolve a severity class to its configured point weight.
919    pub fn points_for(&self, severity: Severity) -> u32 {
920        match severity {
921            Severity::Critical => self.critical,
922            Severity::Error => self.error,
923            Severity::Warning => self.warning,
924            Severity::Notice => self.notice,
925        }
926    }
927}
928
929#[derive(Debug, Clone, Deserialize)]
930pub struct ProxyConfig {
931    pub listen: std::net::SocketAddr,
932    pub backend: String,
933}
934
935#[derive(Debug, Clone, Deserialize)]
936pub struct WafConfig {
937    pub mode: WafMode,
938    // NOTE: the old `fail_open: bool` was removed in favour of the per-scenario
939    // `[resilience]` section. A leftover `waf.fail_open` in TOML is rejected with
940    // a migration hint at load time (see waf-proxy::config), never silently.
941    #[serde(default = "default_block_threshold")]
942    pub block_threshold: u32,
943    /// Paranoia level (1..=4). Higher levels activate more (and noisier) rules.
944    #[serde(default = "default_paranoia_level")]
945    pub paranoia_level: u8,
946    /// Point weight per severity class. Used by the pipeline to score matches.
947    #[serde(default)]
948    pub severity_scores: SeverityScores,
949}
950
951fn default_block_threshold() -> u32 {
952    5
953}
954
955fn default_paranoia_level() -> u8 {
956    1
957}
958
959impl Default for ProxyConfig {
960    /// Loopback placeholder for programmatic/test construction; production supplies
961    /// `[proxy]` from TOML.
962    fn default() -> Self {
963        Self {
964            listen: "127.0.0.1:8080".parse().expect("valid loopback addr"),
965            backend: "http://localhost:8080".to_string(),
966        }
967    }
968}
969
970impl Default for WafConfig {
971    /// Detection-only, default thresholds — a non-disruptive base (never blocks until
972    /// explicitly switched to `Blocking`).
973    fn default() -> Self {
974        Self {
975            mode: WafMode::DetectionOnly,
976            block_threshold: default_block_threshold(),
977            paranoia_level: default_paranoia_level(),
978            severity_scores: SeverityScores::default(),
979        }
980    }
981}
982
983#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
984#[serde(rename_all = "kebab-case")]
985pub enum WafMode {
986    DetectionOnly,
987    Blocking,
988}
989
990// `#[non_exhaustive]`: taken by-value by the public `Normalizer::new(&LimitsConfig)`.
991#[derive(Debug, Clone, Deserialize)]
992#[non_exhaustive]
993pub struct LimitsConfig {
994    #[serde(default = "default_max_body_size")]
995    pub max_body_size: usize,
996    #[serde(default = "default_max_header_size")]
997    pub max_header_size: usize,
998    #[serde(default = "default_max_headers")]
999    pub max_headers: usize,
1000    #[serde(default = "default_max_params")]
1001    pub max_params: usize,
1002    #[serde(default = "default_max_cookies")]
1003    pub max_cookies: usize,
1004    #[serde(default = "default_max_json_depth")]
1005    pub max_json_depth: usize,
1006}
1007
1008fn default_max_body_size() -> usize { 1_048_576 } // 1 MiB
1009fn default_max_header_size() -> usize { 8_192 }   // 8 KiB
1010fn default_max_headers() -> usize { 100 }
1011fn default_max_params() -> usize { 100 }
1012fn default_max_cookies() -> usize { 50 }
1013fn default_max_json_depth() -> usize { 20 }
1014
1015impl Default for LimitsConfig {
1016    fn default() -> Self {
1017        Self {
1018            max_body_size: default_max_body_size(),
1019            max_header_size: default_max_header_size(),
1020            max_headers: default_max_headers(),
1021            max_params: default_max_params(),
1022            max_cookies: default_max_cookies(),
1023            max_json_depth: default_max_json_depth(),
1024        }
1025    }
1026}
1027
1028// ── Parsed / Normalized types ─────────────────────────────────────────────────
1029
1030/// Parsed body, populated by the normalizer based on Content-Type.
1031#[derive(Debug, Clone, Default)]
1032pub enum ParsedBody {
1033    #[default]
1034    None,
1035    FormUrlEncoded(Vec<(String, String)>),
1036    Multipart(Vec<MultipartField>),
1037    /// JSON flattened to (dot-path, string-value) pairs for pattern inspection.
1038    JsonFlattened(Vec<(String, String)>),
1039    Raw(Bytes),
1040}
1041
1042#[derive(Debug, Clone)]
1043pub struct MultipartField {
1044    pub name: String,
1045    pub filename: Option<String>,
1046    pub content_type: Option<String>,
1047    pub data: Bytes,
1048}
1049
1050/// Canonicalized version of all inspectable fields.
1051/// Populated by the normalizer before any detection module runs.
1052/// Raw originals remain in RequestContext fields.
1053#[derive(Debug, Clone, Default)]
1054pub struct Normalized {
1055    /// URL-decoded, traversal-resolved, lowercased path.
1056    pub path: String,
1057    /// URL-decoded query string (raw, single decode).
1058    pub query: Option<String>,
1059    /// Parsed, decoded query parameters — repeated names are all kept.
1060    pub query_params: Vec<(String, String)>,
1061    /// Parsed cookies (from Cookie headers).
1062    pub cookies: Vec<(String, String)>,
1063    /// Header names lowercased, values trimmed.
1064    pub headers: Vec<(String, String)>,
1065    pub body: ParsedBody,
1066    /// True when any field had a percent-encoded sequence that decoded to another percent-encoded sequence.
1067    pub double_encoding_detected: bool,
1068    /// True when a NUL byte was present in the DECODED path *before* the normalizer
1069    /// stripped it (F-1 evasion signal). Set PRE-strip on the PATH only — the sole
1070    /// channel where `pt-null-byte` is structurally blind (the normalizer strips NUL
1071    /// from the path but query/cookie/body keep it, so those stay covered by
1072    /// `pt-null-byte`). Consumed by the `evasion` module → `evasion-null-byte`
1073    /// (Critical), so a stripped-then-forwarded NUL leaves a trace instead of vanishing.
1074    pub null_byte_detected: bool,
1075    /// Phase 10c: additional inspection-only strings DERIVED from field values that
1076    /// were base64-encoded (gotestwaf Base64Flat). Each entry is the base64-decoded +
1077    /// canonicalized form of some query/cookie/body/header value that passed the
1078    /// base64 candidacy gate. "decode-then-match-then-discard": modules and the
1079    /// prefilter inspect these alongside the real fields, but they are NEVER persisted
1080    /// as a real field value — a derived string that matches no rule has no effect.
1081    pub derived_decoded: Vec<String>,
1082}
1083
1084// ── Scoring audit ───────────────────────────────────────────────────────────
1085
1086/// One recorded contribution to the anomaly score, kept for audit/logging.
1087/// Populated exclusively by the pipeline as it accumulates `ctx.score`.
1088///
1089/// `Serialize`/`Deserialize` so the per-rule breakdown can be emitted in the decision-log
1090/// (the core logs the array on a denied request) and reconstructed downstream — this is the
1091/// data the enterprise control-plane drill-down (§7) rebuilds the verdict from.
1092#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093pub struct ScoreContribution {
1094    /// Id of the module that produced the contribution.
1095    pub module: String,
1096    /// Id of the rule that matched.
1097    pub rule_id: String,
1098    /// Severity class, when the contribution came from `Decision::Scores`.
1099    /// `None` for direct `Decision::Score` contributions.
1100    pub severity: Option<Severity>,
1101    /// Points actually added to `ctx.score`.
1102    pub points: u32,
1103}
1104
1105#[cfg(test)]
1106mod scoring_serde_tests {
1107    use super::*;
1108
1109    /// Locks the decision-log `score_contributions` JSON shape — the §7 control-plane
1110    /// drill-down parses exactly this. Field names are snake_case; severity is lowercase;
1111    /// a `Score`-derived contribution has `severity: null`.
1112    #[test]
1113    fn score_contribution_json_shape_is_stable() {
1114        let items = vec![
1115            ScoreContribution {
1116                module: "sqli".to_string(),
1117                rule_id: "sqli-union-select".to_string(),
1118                severity: Some(Severity::Critical),
1119                points: 5,
1120            },
1121            ScoreContribution {
1122                module: "rate_limit".to_string(),
1123                rule_id: "rl-client-ip".to_string(),
1124                severity: None,
1125                points: 3,
1126            },
1127        ];
1128        let json = serde_json::to_string(&items).unwrap();
1129        assert_eq!(
1130            json,
1131            r#"[{"module":"sqli","rule_id":"sqli-union-select","severity":"critical","points":5},{"module":"rate_limit","rule_id":"rl-client-ip","severity":null,"points":3}]"#
1132        );
1133        // Round-trips back into the same typed model the control plane will reuse.
1134        let back: Vec<ScoreContribution> = serde_json::from_str(&json).unwrap();
1135        assert_eq!(back, items);
1136    }
1137}
1138
1139// ── RequestContext ────────────────────────────────────────────────────────────
1140
1141#[derive(Debug)]
1142pub struct RequestContext {
1143    // Identity
1144    pub client_ip: IpAddr,
1145    pub request_id: String,
1146    pub timestamp: SystemTime,
1147    // Request line (raw)
1148    pub method: String,
1149    pub path: String,
1150    pub raw_path: String,
1151    pub query: Option<String>,
1152    pub http_version: String,
1153    // Headers & cookies (raw, as received)
1154    pub headers: Vec<(String, String)>,
1155    pub cookies: Vec<(String, String)>,
1156    // Body bytes (collected before pipeline; streaming deferred to Fase 4+)
1157    pub body: Bytes,
1158    // Canonical forms — populated by the normalizer, read by detection modules
1159    pub normalized: Normalized,
1160    // Anomaly score accumulated across modules
1161    pub score: u32,
1162    // Per-rule breakdown of how `score` was reached (filled by the pipeline)
1163    pub score_contributions: Vec<ScoreContribution>,
1164}
1165
1166// ── tests ─────────────────────────────────────────────────────────────────────
1167
1168#[cfg(test)]
1169mod config_validation_tests {
1170    use super::*;
1171
1172    fn valid() -> Config {
1173        Config {
1174            proxy: ProxyConfig {
1175                listen: "127.0.0.1:8080".parse().unwrap(),
1176                backend: "http://localhost:3000".to_string(),
1177            },
1178            waf: WafConfig {
1179                mode: WafMode::Blocking,
1180                block_threshold: 5,
1181                paranoia_level: 2,
1182                severity_scores: SeverityScores::default(),
1183            },
1184            limits: LimitsConfig::default(),
1185            modules: ModulesConfig::default(),
1186            rate_limit: RateLimitConfig::default(),
1187            network: NetworkConfig::default(),
1188            resilience: ResilienceConfig::default(),
1189            tls: TlsConfig::default(),
1190            metrics: MetricsConfig::default(),
1191        }
1192    }
1193
1194    #[test]
1195    fn valid_config_passes() {
1196        assert!(valid().validate().is_ok());
1197    }
1198
1199    #[test]
1200    fn paranoia_4_is_legal_forward_compatible() {
1201        let mut c = valid();
1202        c.waf.paranoia_level = MAX_PARANOIA_LEVEL; // 4: empty-but-legal
1203        assert!(c.validate().is_ok());
1204    }
1205
1206    #[test]
1207    fn paranoia_out_of_range_rejected() {
1208        let mut c = valid();
1209        c.waf.paranoia_level = 0;
1210        assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(0)));
1211        c.waf.paranoia_level = 5;
1212        assert_eq!(c.validate(), Err(ConfigError::ParanoiaOutOfRange(5)));
1213    }
1214
1215    #[test]
1216    fn block_threshold_zero_rejected() {
1217        let mut c = valid();
1218        c.waf.block_threshold = 0;
1219        assert_eq!(c.validate(), Err(ConfigError::BlockThresholdZero));
1220    }
1221
1222    #[test]
1223    fn grpc_disabled_ignores_caps() {
1224        // Default grpc is off → caps not validated (cleartext/no-gRPC deploy).
1225        assert!(valid().validate().is_ok());
1226    }
1227
1228    #[test]
1229    fn grpc_enabled_rejects_zero_caps() {
1230        let mut c = valid();
1231        c.modules.grpc.enabled = true;
1232        assert!(c.validate().is_ok()); // defaults are all >= 1
1233        c.modules.grpc.max_depth = 0;
1234        assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_depth")));
1235        c.modules.grpc.max_depth = 16;
1236        c.modules.grpc.max_message_bytes = 0;
1237        assert_eq!(c.validate(), Err(ConfigError::GrpcCapZero("max_message_bytes")));
1238    }
1239
1240    #[test]
1241    fn tls_disabled_ignores_empty_paths() {
1242        // Default TLS is off → empty paths must not trip validation (cleartext deploy).
1243        assert!(valid().validate().is_ok());
1244    }
1245
1246    #[test]
1247    fn tls_enabled_requires_cert_and_key_paths() {
1248        let mut c = valid();
1249        c.tls.enabled = true;
1250        assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("cert_path")));
1251        c.tls.cert_path = "cert.pem".to_string();
1252        assert_eq!(c.validate(), Err(ConfigError::TlsPathEmpty("key_path")));
1253        c.tls.key_path = "key.pem".to_string();
1254        assert!(c.validate().is_ok());
1255    }
1256
1257    #[test]
1258    fn tls_enabled_rejects_empty_alpn() {
1259        let mut c = valid();
1260        c.tls.enabled = true;
1261        c.tls.cert_path = "cert.pem".to_string();
1262        c.tls.key_path = "key.pem".to_string();
1263        c.tls.alpn = vec![];
1264        assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1265        c.tls.alpn = vec!["h2".to_string(), "  ".to_string()];
1266        assert_eq!(c.validate(), Err(ConfigError::TlsAlpnInvalid));
1267    }
1268
1269    #[test]
1270    fn crs_enabled_requires_files() {
1271        let mut c = valid();
1272        c.modules.crs.enabled = true;
1273        assert_eq!(c.validate(), Err(ConfigError::CrsNoFiles));
1274        c.modules.crs.files = vec!["rules.conf".to_string()];
1275        assert!(c.validate().is_ok());
1276    }
1277
1278    #[test]
1279    fn crs_disabled_ignores_empty_files() {
1280        // Default CRS off → empty file list must not trip validation.
1281        assert!(valid().validate().is_ok());
1282    }
1283
1284    #[test]
1285    fn wasm_enabled_requires_plugins_with_paths() {
1286        let mut c = valid();
1287        c.modules.wasm.enabled = true;
1288        assert_eq!(c.validate(), Err(ConfigError::WasmNoPlugins));
1289        c.modules.wasm.plugins = vec![WasmPluginConfig { path: "  ".into(), config: None }];
1290        assert_eq!(c.validate(), Err(ConfigError::WasmPluginPathEmpty));
1291        c.modules.wasm.plugins = vec![WasmPluginConfig { path: "f.wasm".into(), config: None }];
1292        assert!(c.validate().is_ok());
1293    }
1294
1295    #[test]
1296    fn wasm_disabled_ignores_empty_plugins() {
1297        // Default WASM off → empty plugin list must not trip validation.
1298        assert!(valid().validate().is_ok());
1299    }
1300
1301    #[test]
1302    fn zero_severity_weight_rejected() {
1303        let mut c = valid();
1304        c.waf.severity_scores.warning = 0;
1305        assert_eq!(c.validate(), Err(ConfigError::SeverityWeightZero("warning")));
1306    }
1307
1308    #[test]
1309    fn zero_limit_rejected() {
1310        let mut c = valid();
1311        c.limits.max_json_depth = 0;
1312        assert_eq!(c.validate(), Err(ConfigError::LimitZero("max_json_depth")));
1313    }
1314
1315    #[test]
1316    fn invalid_backend_rejected() {
1317        let mut c = valid();
1318        c.proxy.backend = "localhost:3000".to_string(); // no scheme
1319        assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1320        c.proxy.backend = "http://".to_string(); // no authority
1321        assert!(matches!(c.validate(), Err(ConfigError::InvalidBackend(_))));
1322    }
1323
1324    #[test]
1325    fn rate_limit_values_checked_only_when_enabled() {
1326        let mut c = valid();
1327        // Disabled: bad values are tolerated (dead config), not a startup error.
1328        c.rate_limit.enabled = false;
1329        c.rate_limit.requests = 0;
1330        assert!(c.validate().is_ok());
1331        // Enabled: the same bad value is rejected.
1332        c.rate_limit.enabled = true;
1333        assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("requests")));
1334    }
1335
1336    #[test]
1337    fn rate_limit_score_action_requires_positive_score() {
1338        let mut c = valid();
1339        c.rate_limit.enabled = true;
1340        c.rate_limit.action = RateLimitAction::Score;
1341        c.rate_limit.score = 0;
1342        assert_eq!(c.validate(), Err(ConfigError::RateLimitValueZero("score")));
1343    }
1344
1345    #[test]
1346    fn trusted_hops_out_of_range_rejected() {
1347        let mut c = valid();
1348        c.network.trusted_hops = 0;
1349        assert_eq!(c.validate(), Err(ConfigError::TrustedHopsOutOfRange(0)));
1350        c.network.trusted_hops = MAX_TRUSTED_HOPS + 1;
1351        assert_eq!(
1352            c.validate(),
1353            Err(ConfigError::TrustedHopsOutOfRange(MAX_TRUSTED_HOPS + 1))
1354        );
1355    }
1356
1357    #[test]
1358    fn invalid_cidr_in_trusted_proxies_rejected() {
1359        let mut c = valid();
1360        c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "999.0.0.0/8".to_string()];
1361        assert_eq!(
1362            c.validate(),
1363            Err(ConfigError::InvalidCidr("999.0.0.0/8".to_string()))
1364        );
1365    }
1366
1367    #[test]
1368    fn valid_cidrs_pass() {
1369        let mut c = valid();
1370        c.network.trusted_proxies = vec!["10.0.0.0/8".to_string(), "::1".to_string()];
1371        assert!(c.validate().is_ok());
1372    }
1373
1374    #[test]
1375    fn empty_client_ip_header_rejected() {
1376        let mut c = valid();
1377        c.network.client_ip_header = "   ".to_string();
1378        assert_eq!(c.validate(), Err(ConfigError::EmptyClientIpHeader));
1379    }
1380
1381    #[test]
1382    fn zero_upstream_timeout_rejected() {
1383        let mut c = valid();
1384        c.resilience.upstream_timeout_ms = 0;
1385        assert_eq!(c.validate(), Err(ConfigError::ResilienceTimeoutZero));
1386    }
1387
1388    #[test]
1389    fn resilience_defaults_match_documented_posture() {
1390        let r = ResilienceConfig::default();
1391        assert_eq!(r.on_internal_error, FailMode::FailOpen);
1392        assert_eq!(r.on_upstream_error, FailMode::FailClosed);
1393        assert_eq!(r.on_config_error, FailMode::FailOpen);
1394        assert_eq!(r.on_parser_limit, FailMode::FailClosed);
1395    }
1396}