Skip to main content

waf_capture/
module.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! The capture `WafModule` — the request-path half of the feature.
5//!
6//! It is injected via the core-0.3 `module_factory` seam and, on every request, clones the request
7//! into the shared [`CaptureRing`]. It **never** produces a verdict (`inspect` always returns
8//! `Decision::Allow`): it is a pure observer. The verdict-join `tracing::Layer` (C2) later takes the
9//! matching capture out of the ring for the requests the WAF actually denied; everything else ages
10//! out. The module cannot itself see the verdict — that is precisely why the join is a separate step.
11//!
12//! **Phase = `RequestLine`, `structural = true`.** The pipeline `break`s at the first block, so a
13//! capture module in a late phase would miss requests denied earlier. `RequestLine` is the first
14//! inspection phase, so the capture runs before every content-rule / anomaly-threshold block (which
15//! land in `Headers`/`Body`). `structural = true` also keeps it running on the content fast-path
16//! skip. Not captured (by construction): `Phase::Connection` denials (rate-limit / request
17//! smuggling) run before inspection, and a hard block from a `RequestLine` module ordered ahead of
18//! this one — both remain metadata-only in the decision-log. The body is available regardless of
19//! phase (collected before the pipeline runs).
20
21use std::sync::Arc;
22
23use waf_core::{Config, Decision, Phase, RequestContext, WafModule};
24
25use crate::{CaptureConfig, CaptureRing, CapturedRequest, RedactionConfig};
26
27/// Observer module that mirrors each request into the ring. Holds an `Arc` to the ring it shares
28/// with the verdict-join layer; the `module_factory` rebuilds the module on reload but keeps the
29/// same ring, so in-flight captures survive a config reload.
30pub struct CaptureModule {
31    enabled: bool,
32    max_body_bytes: usize,
33    redaction: RedactionConfig,
34    ring: Arc<CaptureRing>,
35}
36
37impl CaptureModule {
38    /// Build the module from the typed config, sharing `ring` with the verdict-join layer.
39    pub fn new(cfg: &CaptureConfig, ring: Arc<CaptureRing>) -> Self {
40        Self {
41            enabled: cfg.enabled,
42            max_body_bytes: cfg.max_body_bytes,
43            redaction: cfg.redaction.clone(),
44            ring,
45        }
46    }
47}
48
49impl WafModule for CaptureModule {
50    fn id(&self) -> &str {
51        "request-capture"
52    }
53
54    fn phase(&self) -> Phase {
55        Phase::RequestLine
56    }
57
58    /// Structural so the content fast-path never skips it (it is not a content rule).
59    fn structural(&self) -> bool {
60        true
61    }
62
63    fn init(&mut self, _cfg: &Config) {}
64
65    fn inspect(&self, ctx: &RequestContext) -> Decision {
66        if self.enabled {
67            let capture = CapturedRequest::from_ctx(ctx, self.max_body_bytes, &self.redaction);
68            self.ring.insert(capture);
69        }
70        // Pure observer — never contributes to the score, never denies.
71        Decision::Allow
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use std::time::Duration;
79    use waf_core::testkit::Request;
80
81    fn ring() -> Arc<CaptureRing> {
82        Arc::new(CaptureRing::new(16, 1 << 20, Duration::from_secs(5)))
83    }
84
85    #[test]
86    fn enabled_module_captures_into_ring_and_allows() {
87        let ring = ring();
88        let cfg = CaptureConfig { enabled: true, ..Default::default() };
89        let m = CaptureModule::new(&cfg, Arc::clone(&ring));
90
91        let ctx = Request::new().request_id("req-1").method("POST").path("/x").json_body("{}").build();
92        assert!(matches!(m.inspect(&ctx), Decision::Allow), "observer never denies");
93
94        let cap = ring.take("req-1").expect("captured");
95        assert_eq!(cap.method, "POST");
96        assert_eq!(cap.path, "/x");
97    }
98
99    #[test]
100    fn disabled_module_captures_nothing() {
101        let ring = ring();
102        let cfg = CaptureConfig { enabled: false, ..Default::default() };
103        let m = CaptureModule::new(&cfg, Arc::clone(&ring));
104
105        let ctx = Request::new().request_id("req-2").build();
106        assert!(matches!(m.inspect(&ctx), Decision::Allow));
107        assert!(ring.is_empty(), "disabled → nothing captured");
108    }
109
110    #[test]
111    fn module_metadata_is_structural_requestline() {
112        let m = CaptureModule::new(&CaptureConfig::default(), ring());
113        assert_eq!(m.id(), "request-capture");
114        assert_eq!(m.phase(), Phase::RequestLine);
115        assert!(m.structural(), "must run on the content fast-path skip");
116    }
117}