Skip to main content

waf_capture/
capture.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! The captured-request DTO and how it is built from a live [`RequestContext`].
5//!
6//! [`CapturedRequest`] is the wire + storage shape: it is what the ring holds (C1), what the shipper
7//! POSTs to the control-plane (C2), and what the CP stores/serves (C3+). The body is carried as
8//! base64 (JSON cannot hold raw bytes; the CP column is `BYTEA`). Two node-side guards are applied
9//! here so nothing sensitive/oversized ever leaves the node:
10//!   - **redaction** — header/cookie/body-field values whose name matches [`RedactionConfig`] are
11//!     replaced with [`REDACTED`]. Body redaction is structured (JSON keys / form params);
12//!   - **truncation** — the stored body is capped at `max_body_bytes`, with the original size and a
13//!     `body_truncated` flag preserved.
14
15use base64::Engine as _;
16use serde::{Deserialize, Serialize};
17use waf_core::RequestContext;
18
19use crate::RedactionConfig;
20
21/// The placeholder a redacted value is replaced with.
22pub const REDACTED: &str = "***REDACTED***";
23
24/// A captured HTTP request — the full request the WAF denied, as forwarded to the control-plane
25/// drill-down. Built by [`CapturedRequest::from_ctx`] with redaction + truncation already applied.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct CapturedRequest {
28    /// Correlation id — the same `request_id` the decision-log carries, so the CP joins this
29    /// capture onto the stored `DecisionEvent`.
30    pub request_id: String,
31    pub method: String,
32    pub path: String,
33    pub query: Option<String>,
34    pub http_version: String,
35    /// Raw request headers as received (names as-sent), redacted values swapped for [`REDACTED`].
36    pub headers: Vec<(String, String)>,
37    /// Parsed cookies, redacted values swapped for [`REDACTED`].
38    pub cookies: Vec<(String, String)>,
39    /// Base64 of the (possibly redacted, possibly truncated) body bytes.
40    pub body_b64: String,
41    /// Original body size in bytes, before any truncation.
42    pub body_size: usize,
43    /// True when the stored body was capped at `max_body_bytes` (the original was larger).
44    pub body_truncated: bool,
45    /// Names (headers / cookies / body fields) that were redacted node-side — drives the UI banner.
46    pub redacted: Vec<String>,
47}
48
49impl CapturedRequest {
50    /// Build a capture from a live request context, applying `redaction` (node-side) and capping the
51    /// stored body at `max_body_bytes`. Reads the RAW request fields (`ctx.headers`/`ctx.cookies`/
52    /// `ctx.body`) — the request *as received*, not the normalized canonical forms.
53    pub fn from_ctx(ctx: &RequestContext, max_body_bytes: usize, redaction: &RedactionConfig) -> Self {
54        let mut redacted: Vec<String> = Vec::new();
55
56        // Headers: redact by header name.
57        let headers = ctx
58            .headers
59            .iter()
60            .map(|(name, value)| {
61                if redaction.header_redacted(name) {
62                    push_unique(&mut redacted, name);
63                    (name.clone(), REDACTED.to_string())
64                } else {
65                    (name.clone(), value.clone())
66                }
67            })
68            .collect();
69
70        // Cookies: a blanket redaction when `cookie`/`set-cookie` is in the header list (the raw
71        // Cookie header carries the same data), else per-cookie by name against the body-field list.
72        let blanket_cookies =
73            redaction.header_redacted("cookie") || redaction.header_redacted("set-cookie");
74        let cookies = ctx
75            .cookies
76            .iter()
77            .map(|(name, value)| {
78                if blanket_cookies || redaction.field_redacted(name) {
79                    push_unique(&mut redacted, name);
80                    (name.clone(), REDACTED.to_string())
81                } else {
82                    (name.clone(), value.clone())
83                }
84            })
85            .collect();
86
87        // Body: structured field redaction (only when it fits the cap — a truncated body cannot be
88        // parsed), then truncate to `max_body_bytes`.
89        let body_size = ctx.body.len();
90        let raw: &[u8] = ctx.body.as_ref();
91        let redacted_body: Vec<u8> = if !redaction.body_fields.is_empty() && body_size <= max_body_bytes {
92            redact_body(raw, content_type(&ctx.headers), redaction, &mut redacted)
93        } else {
94            raw.to_vec()
95        };
96        let body_truncated = redacted_body.len() > max_body_bytes;
97        let stored = &redacted_body[..redacted_body.len().min(max_body_bytes)];
98        let body_b64 = base64::engine::general_purpose::STANDARD.encode(stored);
99
100        Self {
101            request_id: ctx.request_id.clone(),
102            method: ctx.method.clone(),
103            path: ctx.path.clone(),
104            query: ctx.query.clone(),
105            http_version: ctx.http_version.clone(),
106            headers,
107            cookies,
108            body_b64,
109            body_size,
110            body_truncated,
111            redacted,
112        }
113    }
114
115    /// Rough in-memory footprint, used by the ring to enforce its byte budget. Dominated by the
116    /// base64 body; header/cookie/line sizes are added so a header-heavy request is accounted for.
117    pub fn estimated_bytes(&self) -> usize {
118        let hdr: usize = self.headers.iter().map(|(k, v)| k.len() + v.len()).sum();
119        let cke: usize = self.cookies.iter().map(|(k, v)| k.len() + v.len()).sum();
120        self.body_b64.len()
121            + hdr
122            + cke
123            + self.request_id.len()
124            + self.method.len()
125            + self.path.len()
126            + self.query.as_ref().map_or(0, |q| q.len())
127    }
128}
129
130/// The `content-type` header value (lowercased), if present. Case-insensitive on the header name.
131fn content_type(headers: &[(String, String)]) -> Option<String> {
132    headers
133        .iter()
134        .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
135        .map(|(_, v)| v.to_ascii_lowercase())
136}
137
138/// Redact body fields by content-type: JSON (keys, recursively) or form-urlencoded (params). An
139/// unrecognized / unparseable body is returned unchanged (structured redaction cannot locate fields).
140fn redact_body(
141    body: &[u8],
142    content_type: Option<String>,
143    redaction: &RedactionConfig,
144    redacted: &mut Vec<String>,
145) -> Vec<u8> {
146    let ct = content_type.unwrap_or_default();
147    if ct.contains("application/json") || ct.contains("+json") {
148        if let Ok(mut v) = serde_json::from_slice::<serde_json::Value>(body) {
149            redact_json(&mut v, redaction, redacted);
150            if let Ok(s) = serde_json::to_vec(&v) {
151                return s;
152            }
153        }
154        body.to_vec()
155    } else if ct.contains("application/x-www-form-urlencoded") {
156        redact_form(body, redaction, redacted)
157    } else {
158        body.to_vec()
159    }
160}
161
162/// Recursively replace, in a JSON value, every object member whose key matches the redaction list.
163fn redact_json(v: &mut serde_json::Value, redaction: &RedactionConfig, redacted: &mut Vec<String>) {
164    match v {
165        serde_json::Value::Object(map) => {
166            for (k, val) in map.iter_mut() {
167                if redaction.field_redacted(k) {
168                    push_unique(redacted, k);
169                    *val = serde_json::Value::String(REDACTED.to_string());
170                } else {
171                    redact_json(val, redaction, redacted);
172                }
173            }
174        }
175        serde_json::Value::Array(items) => {
176            for item in items.iter_mut() {
177                redact_json(item, redaction, redacted);
178            }
179        }
180        _ => {}
181    }
182}
183
184/// Replace the value of any `key=value` pair whose key matches the redaction list. Keys are compared
185/// as sent (case-insensitively); values are replaced verbatim with the placeholder.
186fn redact_form(body: &[u8], redaction: &RedactionConfig, redacted: &mut Vec<String>) -> Vec<u8> {
187    let text = String::from_utf8_lossy(body);
188    let out = text
189        .split('&')
190        .map(|pair| match pair.split_once('=') {
191            Some((key, _)) if redaction.field_redacted(key) => {
192                push_unique(redacted, key);
193                format!("{key}={REDACTED}")
194            }
195            _ => pair.to_string(),
196        })
197        .collect::<Vec<_>>()
198        .join("&");
199    out.into_bytes()
200}
201
202/// Push `name` into `redacted` unless already present (case-insensitively) — keeps the UI banner list
203/// free of duplicates when the same field appears more than once.
204fn push_unique(redacted: &mut Vec<String>, name: &str) {
205    if !redacted.iter().any(|n| n.eq_ignore_ascii_case(name)) {
206        redacted.push(name.to_string());
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use waf_core::testkit::Request;
214
215    fn decode(b64: &str) -> String {
216        String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap()
217    }
218
219    #[test]
220    fn raw_capture_keeps_everything() {
221        let ctx = Request::new()
222            .method("POST")
223            .path("/login")
224            .header("authorization", "Bearer secret")
225            .json_body(r#"{"user":"a","password":"p"}"#)
226            .build();
227        let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &RedactionConfig::default());
228        assert_eq!(cap.method, "POST");
229        assert_eq!(cap.path, "/login");
230        assert!(cap.redacted.is_empty(), "no redaction configured");
231        assert!(cap.headers.iter().any(|(k, v)| k == "authorization" && v == "Bearer secret"));
232        assert_eq!(decode(&cap.body_b64), r#"{"user":"a","password":"p"}"#);
233        assert!(!cap.body_truncated);
234    }
235
236    #[test]
237    fn redacts_headers_and_json_body_fields() {
238        let ctx = Request::new()
239            .method("POST")
240            .path("/login")
241            .header("Authorization", "Bearer secret")
242            .header("user-agent", "curl/8")
243            .json_body(r#"{"user":"alice","password":"hunter2","nested":{"token":"t"}}"#)
244            .build();
245        let red = RedactionConfig {
246            headers: vec!["authorization".to_string()],
247            body_fields: vec!["password".to_string(), "token".to_string()],
248        };
249        let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &red);
250
251        // Header redacted, unrelated header intact.
252        assert!(cap.headers.iter().any(|(k, v)| k == "Authorization" && v == REDACTED));
253        assert!(cap.headers.iter().any(|(k, v)| k == "user-agent" && v == "curl/8"));
254
255        // JSON body fields redacted (top-level + nested), other fields intact.
256        let body = decode(&cap.body_b64);
257        let v: serde_json::Value = serde_json::from_str(&body).unwrap();
258        assert_eq!(v["password"], REDACTED);
259        assert_eq!(v["nested"]["token"], REDACTED);
260        assert_eq!(v["user"], "alice");
261
262        // Names are recorded as-encountered (header `Authorization` as sent, body keys as in JSON).
263        assert!(cap.redacted.contains(&"Authorization".to_string()));
264        assert!(cap.redacted.contains(&"password".to_string()));
265        assert!(cap.redacted.contains(&"token".to_string()));
266    }
267
268    #[test]
269    fn redacts_form_body_fields() {
270        let ctx = Request::new()
271            .method("POST")
272            .path("/login")
273            .form_body("user=alice&password=hunter2&remember=1")
274            .build();
275        let red = RedactionConfig { headers: vec![], body_fields: vec!["password".to_string()] };
276        let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &red);
277        let body = decode(&cap.body_b64);
278        assert!(body.contains("password=***REDACTED***"));
279        assert!(body.contains("user=alice"));
280        assert!(body.contains("remember=1"));
281    }
282
283    #[test]
284    fn blanket_cookie_redaction_when_cookie_header_listed() {
285        let mut ctx = Request::new().path("/").build();
286        ctx.cookies = vec![("sid".to_string(), "abc".to_string())];
287        let red = RedactionConfig { headers: vec!["cookie".to_string()], body_fields: vec![] };
288        let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &red);
289        assert_eq!(cap.cookies, vec![("sid".to_string(), REDACTED.to_string())]);
290    }
291
292    #[test]
293    fn truncates_oversized_body_and_flags_it() {
294        let big = "A".repeat(1000);
295        let ctx = Request::new().method("POST").path("/upload").body(big.into_bytes(), "text/plain").build();
296        let cap = CapturedRequest::from_ctx(&ctx, 100, &RedactionConfig::default());
297        assert_eq!(cap.body_size, 1000, "original size preserved");
298        assert!(cap.body_truncated);
299        assert_eq!(decode(&cap.body_b64).len(), 100, "stored body capped at max_body_bytes");
300    }
301}