use base64::Engine as _;
use serde::{Deserialize, Serialize};
use waf_core::RequestContext;
use crate::RedactionConfig;
pub const REDACTED: &str = "***REDACTED***";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapturedRequest {
pub request_id: String,
pub method: String,
pub path: String,
pub query: Option<String>,
pub http_version: String,
pub headers: Vec<(String, String)>,
pub cookies: Vec<(String, String)>,
pub body_b64: String,
pub body_size: usize,
pub body_truncated: bool,
pub redacted: Vec<String>,
}
impl CapturedRequest {
pub fn from_ctx(ctx: &RequestContext, max_body_bytes: usize, redaction: &RedactionConfig) -> Self {
let mut redacted: Vec<String> = Vec::new();
let headers = ctx
.headers
.iter()
.map(|(name, value)| {
if redaction.header_redacted(name) {
push_unique(&mut redacted, name);
(name.clone(), REDACTED.to_string())
} else {
(name.clone(), value.clone())
}
})
.collect();
let blanket_cookies =
redaction.header_redacted("cookie") || redaction.header_redacted("set-cookie");
let cookies = ctx
.cookies
.iter()
.map(|(name, value)| {
if blanket_cookies || redaction.field_redacted(name) {
push_unique(&mut redacted, name);
(name.clone(), REDACTED.to_string())
} else {
(name.clone(), value.clone())
}
})
.collect();
let body_size = ctx.body.len();
let raw: &[u8] = ctx.body.as_ref();
let redacted_body: Vec<u8> = if !redaction.body_fields.is_empty() && body_size <= max_body_bytes {
redact_body(raw, content_type(&ctx.headers), redaction, &mut redacted)
} else {
raw.to_vec()
};
let body_truncated = redacted_body.len() > max_body_bytes;
let stored = &redacted_body[..redacted_body.len().min(max_body_bytes)];
let body_b64 = base64::engine::general_purpose::STANDARD.encode(stored);
Self {
request_id: ctx.request_id.clone(),
method: ctx.method.clone(),
path: ctx.path.clone(),
query: ctx.query.clone(),
http_version: ctx.http_version.clone(),
headers,
cookies,
body_b64,
body_size,
body_truncated,
redacted,
}
}
pub fn estimated_bytes(&self) -> usize {
let hdr: usize = self.headers.iter().map(|(k, v)| k.len() + v.len()).sum();
let cke: usize = self.cookies.iter().map(|(k, v)| k.len() + v.len()).sum();
self.body_b64.len()
+ hdr
+ cke
+ self.request_id.len()
+ self.method.len()
+ self.path.len()
+ self.query.as_ref().map_or(0, |q| q.len())
}
}
fn content_type(headers: &[(String, String)]) -> Option<String> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.to_ascii_lowercase())
}
fn redact_body(
body: &[u8],
content_type: Option<String>,
redaction: &RedactionConfig,
redacted: &mut Vec<String>,
) -> Vec<u8> {
let ct = content_type.unwrap_or_default();
if ct.contains("application/json") || ct.contains("+json") {
if let Ok(mut v) = serde_json::from_slice::<serde_json::Value>(body) {
redact_json(&mut v, redaction, redacted);
if let Ok(s) = serde_json::to_vec(&v) {
return s;
}
}
body.to_vec()
} else if ct.contains("application/x-www-form-urlencoded") {
redact_form(body, redaction, redacted)
} else {
body.to_vec()
}
}
fn redact_json(v: &mut serde_json::Value, redaction: &RedactionConfig, redacted: &mut Vec<String>) {
match v {
serde_json::Value::Object(map) => {
for (k, val) in map.iter_mut() {
if redaction.field_redacted(k) {
push_unique(redacted, k);
*val = serde_json::Value::String(REDACTED.to_string());
} else {
redact_json(val, redaction, redacted);
}
}
}
serde_json::Value::Array(items) => {
for item in items.iter_mut() {
redact_json(item, redaction, redacted);
}
}
_ => {}
}
}
fn redact_form(body: &[u8], redaction: &RedactionConfig, redacted: &mut Vec<String>) -> Vec<u8> {
let text = String::from_utf8_lossy(body);
let out = text
.split('&')
.map(|pair| match pair.split_once('=') {
Some((key, _)) if redaction.field_redacted(key) => {
push_unique(redacted, key);
format!("{key}={REDACTED}")
}
_ => pair.to_string(),
})
.collect::<Vec<_>>()
.join("&");
out.into_bytes()
}
fn push_unique(redacted: &mut Vec<String>, name: &str) {
if !redacted.iter().any(|n| n.eq_ignore_ascii_case(name)) {
redacted.push(name.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use waf_core::testkit::Request;
fn decode(b64: &str) -> String {
String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap()
}
#[test]
fn raw_capture_keeps_everything() {
let ctx = Request::new()
.method("POST")
.path("/login")
.header("authorization", "Bearer secret")
.json_body(r#"{"user":"a","password":"p"}"#)
.build();
let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &RedactionConfig::default());
assert_eq!(cap.method, "POST");
assert_eq!(cap.path, "/login");
assert!(cap.redacted.is_empty(), "no redaction configured");
assert!(cap.headers.iter().any(|(k, v)| k == "authorization" && v == "Bearer secret"));
assert_eq!(decode(&cap.body_b64), r#"{"user":"a","password":"p"}"#);
assert!(!cap.body_truncated);
}
#[test]
fn redacts_headers_and_json_body_fields() {
let ctx = Request::new()
.method("POST")
.path("/login")
.header("Authorization", "Bearer secret")
.header("user-agent", "curl/8")
.json_body(r#"{"user":"alice","password":"hunter2","nested":{"token":"t"}}"#)
.build();
let red = RedactionConfig {
headers: vec!["authorization".to_string()],
body_fields: vec!["password".to_string(), "token".to_string()],
};
let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &red);
assert!(cap.headers.iter().any(|(k, v)| k == "Authorization" && v == REDACTED));
assert!(cap.headers.iter().any(|(k, v)| k == "user-agent" && v == "curl/8"));
let body = decode(&cap.body_b64);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["password"], REDACTED);
assert_eq!(v["nested"]["token"], REDACTED);
assert_eq!(v["user"], "alice");
assert!(cap.redacted.contains(&"Authorization".to_string()));
assert!(cap.redacted.contains(&"password".to_string()));
assert!(cap.redacted.contains(&"token".to_string()));
}
#[test]
fn redacts_form_body_fields() {
let ctx = Request::new()
.method("POST")
.path("/login")
.form_body("user=alice&password=hunter2&remember=1")
.build();
let red = RedactionConfig { headers: vec![], body_fields: vec!["password".to_string()] };
let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &red);
let body = decode(&cap.body_b64);
assert!(body.contains("password=***REDACTED***"));
assert!(body.contains("user=alice"));
assert!(body.contains("remember=1"));
}
#[test]
fn blanket_cookie_redaction_when_cookie_header_listed() {
let mut ctx = Request::new().path("/").build();
ctx.cookies = vec![("sid".to_string(), "abc".to_string())];
let red = RedactionConfig { headers: vec!["cookie".to_string()], body_fields: vec![] };
let cap = CapturedRequest::from_ctx(&ctx, 64 * 1024, &red);
assert_eq!(cap.cookies, vec![("sid".to_string(), REDACTED.to_string())]);
}
#[test]
fn truncates_oversized_body_and_flags_it() {
let big = "A".repeat(1000);
let ctx = Request::new().method("POST").path("/upload").body(big.into_bytes(), "text/plain").build();
let cap = CapturedRequest::from_ctx(&ctx, 100, &RedactionConfig::default());
assert_eq!(cap.body_size, 1000, "original size preserved");
assert!(cap.body_truncated);
assert_eq!(decode(&cap.body_b64).len(), 100, "stored body capped at max_body_bytes");
}
}