1use base64::Engine as _;
16use serde::{Deserialize, Serialize};
17use waf_core::RequestContext;
18
19use crate::RedactionConfig;
20
21pub const REDACTED: &str = "***REDACTED***";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct CapturedRequest {
28 pub request_id: String,
31 pub method: String,
32 pub path: String,
33 pub query: Option<String>,
34 pub http_version: String,
35 pub headers: Vec<(String, String)>,
37 pub cookies: Vec<(String, String)>,
39 pub body_b64: String,
41 pub body_size: usize,
43 pub body_truncated: bool,
45 pub redacted: Vec<String>,
47}
48
49impl CapturedRequest {
50 pub fn from_ctx(ctx: &RequestContext, max_body_bytes: usize, redaction: &RedactionConfig) -> Self {
54 let mut redacted: Vec<String> = Vec::new();
55
56 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 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 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 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
130fn 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
138fn 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
162fn 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
184fn 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
202fn 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 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 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 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}