#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::collections::BTreeSet;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use bytes::Bytes;
use crate::capsule::schema::{BindValue, CapsuleBody, CapsuleRequest};
use crate::log::filter::{FILTERED_PLACEHOLDER, ParameterFilter};
#[derive(Debug, Clone)]
pub struct RawRequest {
pub method: String,
pub uri: axum::http::Uri,
pub version: axum::http::Version,
pub headers: axum::http::HeaderMap,
pub route: Option<String>,
}
#[derive(Debug, Clone)]
pub enum CapturedBody {
Absent,
Buffered(Bytes),
Skipped {
declared_len: Option<usize>,
},
}
#[derive(Debug, Clone, Default)]
pub struct RedactedValues {
values: BTreeSet<Vec<u8>>,
whole_token_only: BTreeSet<Vec<u8>>,
direct: BTreeSet<Vec<u8>>,
}
impl RedactedValues {
pub fn insert(&mut self, value: &[u8]) {
if value.is_empty() {
return;
}
self.values.insert(value.to_vec());
self.direct.insert(value.to_vec());
}
pub fn insert_whole_token_only(&mut self, value: &[u8]) {
if value.is_empty() {
return;
}
self.values.insert(value.to_vec());
self.whole_token_only.insert(value.to_vec());
}
#[must_use]
pub fn contains(&self, value: &[u8]) -> bool {
self.values.contains(value)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
fn is_whole_token_only(&self, value: &[u8]) -> bool {
self.whole_token_only.contains(value) && !self.direct.contains(value)
}
fn longest_first(&self) -> Vec<&[u8]> {
let mut values: Vec<&[u8]> = self.values.iter().map(Vec::as_slice).collect();
values.sort_by_key(|value| std::cmp::Reverse(value.len()));
values
}
}
const MIN_ECHO_LEN: usize = 4;
#[must_use]
pub fn redact_request(
raw: &RawRequest,
raw_body: &CapturedBody,
filter: &ParameterFilter,
) -> (CapsuleRequest, RedactedValues, Vec<String>) {
let mut values = RedactedValues::default();
let mut keys = BTreeSet::new();
let mut notes = Vec::new();
let (headers, binary_headers) = redact_headers(&raw.headers, filter, &mut values, &mut keys);
let uri = redact_uri(&raw.uri, filter, &mut values, &mut keys);
let body = redact_body(
&raw.headers,
raw_body,
filter,
&mut values,
&mut keys,
&mut notes,
);
let request = CapsuleRequest {
method: raw.method.clone(),
uri,
route: raw.route.clone(),
http_version: format!("{:?}", raw.version),
headers,
binary_headers,
body,
redacted_keys: keys.into_iter().collect(),
peer_addr: None,
client_addr: None,
client_host: None,
client_scheme: None,
};
(request, values, notes)
}
#[must_use]
pub fn mask_echoes(text: &str, redacted: &RedactedValues) -> String {
if redacted.is_empty() || text.is_empty() {
return text.to_owned();
}
let mut needles: Vec<(&str, bool)> = Vec::new();
for value in redacted.longest_first() {
let Ok(needle) = std::str::from_utf8(value) else {
continue;
};
if needle.is_empty() {
continue;
}
let whole_token_only = needle.len() < MIN_ECHO_LEN || redacted.is_whole_token_only(value);
needles.push((needle, whole_token_only));
}
let mut masked = String::with_capacity(text.len());
let mut cursor = 0_usize;
while let Some(rest) = text.get(cursor..).filter(|rest| !rest.is_empty()) {
let matched = needles.iter().find(|(needle, whole_token_only)| {
rest.starts_with(*needle)
&& (!*whole_token_only || stands_alone(text, cursor, needle, rest))
});
if let Some((needle, _)) = matched {
masked.push_str(FILTERED_PLACEHOLDER);
cursor = cursor.saturating_add(needle.len());
} else if let Some(next) = rest.chars().next() {
masked.push(next);
cursor = cursor.saturating_add(next.len_utf8());
} else {
break;
}
}
masked
}
fn stands_alone(text: &str, cursor: usize, needle: &str, rest: &str) -> bool {
let head = text.get(..cursor).unwrap_or_default();
let tail = rest.get(needle.len()..).unwrap_or_default();
let mut before = head.chars().rev();
let joined_before = match before.next() {
Some('.') => before.next().is_some_and(char::is_alphanumeric),
Some(character) => is_identifier_char(character),
None => false,
};
let mut after = tail.chars();
let joined_after = match after.next() {
Some('.') => after.next().is_some_and(char::is_alphanumeric),
Some(character) => is_identifier_char(character),
None => false,
};
!joined_before && !joined_after
}
fn is_identifier_char(character: char) -> bool {
character.is_alphanumeric() || character == '-' || character == '_'
}
pub fn mask_binds(binds: &mut [BindValue], redacted: &RedactedValues) {
if redacted.is_empty() {
return;
}
for bind in binds {
if let BindValue::Value(bytes) = bind
&& redacted.contains(bytes)
{
*bind = BindValue::Masked;
}
}
}
const ALWAYS_SENSITIVE_HEADERS: &[&str] = &["proxy-authorization"];
fn header_is_sensitive(name: &str, filter: &ParameterFilter) -> bool {
filter.matches_key(name) || ALWAYS_SENSITIVE_HEADERS.contains(&name)
}
type RedactedHeaders = (Vec<(String, String)>, Vec<(String, String)>);
fn redact_headers(
headers: &axum::http::HeaderMap,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
) -> RedactedHeaders {
let mut binary_names: BTreeSet<String> = BTreeSet::new();
for (name, value) in headers {
if !header_is_sensitive(name.as_str(), filter) && value.to_str().is_err() {
binary_names.insert(name.as_str().to_owned());
}
}
let mut out = Vec::with_capacity(headers.len());
let mut binary = Vec::new();
for (name, value) in headers {
let name = name.as_str().to_owned();
if header_is_sensitive(&name, filter) {
values.insert(value.as_bytes());
record_credential_components(&name, value.as_bytes(), values);
keys.insert(format!("header:{name}"));
out.push((name, FILTERED_PLACEHOLDER.to_owned()));
} else if binary_names.contains(&name) {
binary.push((name, STANDARD.encode(value.as_bytes())));
} else {
out.push((name, value.to_str().unwrap_or_default().to_owned()));
}
}
(out, binary)
}
fn record_credential_components(name: &str, value: &[u8], values: &mut RedactedValues) {
let Ok(text) = std::str::from_utf8(value) else {
return;
};
let trimmed = text.trim();
let name = name.to_ascii_lowercase();
let is_authorization = matches!(name.as_str(), "authorization" | "proxy-authorization");
let is_cookie = matches!(name.as_str(), "cookie" | "set-cookie");
if is_authorization && let Some((scheme, credential)) = trimmed.split_once(' ') {
let credential = credential.trim();
if is_auth_scheme(scheme) && !credential.is_empty() {
values.insert(credential.as_bytes());
if scheme.eq_ignore_ascii_case("basic") {
record_basic_credentials(credential, values);
}
if !is_token68(credential) {
record_auth_params(credential, values);
}
}
}
if is_cookie && trimmed.contains('=') {
let pairs: Vec<&str> = if name == "set-cookie" {
trimmed.split(';').take(1).collect()
} else {
trimmed.split(';').collect()
};
for pair in pairs {
if let Some((_, cookie_value)) = pair.split_once('=') {
let cookie_value = cookie_value.trim().trim_matches('"');
if !cookie_value.is_empty() {
values.insert_whole_token_only(cookie_value.as_bytes());
}
}
}
}
}
fn record_auth_params(credential: &str, values: &mut RedactedValues) {
for param in split_auth_params(credential) {
let Some((name, value)) = param.split_once('=') else {
continue;
};
if !is_auth_scheme(name.trim()) {
continue;
}
let value = unquote_auth_param(value.trim());
if !value.is_empty() {
values.insert_whole_token_only(value.as_bytes());
}
}
}
fn is_token68(credential: &str) -> bool {
let unpadded = credential.trim_end_matches('=');
!unpadded.is_empty()
&& unpadded.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
})
}
fn split_auth_params(credential: &str) -> Vec<String> {
let mut params = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut escaped = false;
for character in credential.chars() {
if escaped {
current.push(character);
escaped = false;
} else if character == '\\' && quoted {
current.push(character);
escaped = true;
} else if character == '"' {
quoted = !quoted;
current.push(character);
} else if character == ',' && !quoted {
params.push(std::mem::take(&mut current));
} else {
current.push(character);
}
}
params.push(current);
params
}
fn unquote_auth_param(value: &str) -> String {
let mut characters = value.chars();
if characters.next() != Some('"') {
return value.to_owned();
}
let mut unquoted = String::new();
let mut escaped = false;
for character in characters {
if escaped {
unquoted.push(character);
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
break;
} else {
unquoted.push(character);
}
}
unquoted
}
fn record_basic_credentials(credential: &str, values: &mut RedactedValues) {
let Ok(decoded) = STANDARD.decode(credential) else {
return;
};
let Some(colon) = decoded.iter().position(|byte| *byte == b':') else {
return;
};
let Some(password) = decoded.get(colon.saturating_add(1)..) else {
return;
};
if !password.is_empty() {
values.insert(password);
}
values.insert(&decoded);
}
fn is_auth_scheme(word: &str) -> bool {
!word.is_empty()
&& word.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
})
}
fn redact_uri(
uri: &axum::http::Uri,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
) -> String {
let Some(query) = uri.query() else {
return uri.to_string();
};
let original = uri.to_string();
let Some(masked) = mask_raw_urlencoded(query, "query", filter, values, keys) else {
return original;
};
let prefix = original.split_once('?').map_or("", |(prefix, _)| prefix);
format!("{prefix}?{masked}")
}
const FILTERED_PLACEHOLDER_URLENCODED: &str = "%5BFILTERED%5D";
fn mask_raw_urlencoded(
raw: &str,
context: &str,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
) -> Option<String> {
let mut changed = false;
let masked: Vec<String> = raw
.split('&')
.map(|raw_pair| {
let Some((decoded_key, decoded_value)) =
url::form_urlencoded::parse(raw_pair.as_bytes()).next()
else {
return raw_pair.to_owned();
};
if !key_is_sensitive(&decoded_key, filter) {
return raw_pair.to_owned();
}
changed = true;
keys.insert(format!("{context}:{decoded_key}"));
if !decoded_value.is_empty() {
values.insert(decoded_value.as_bytes());
}
let (raw_key, raw_value) = raw_pair
.split_once('=')
.map_or((raw_pair, ""), |(key, value)| (key, value));
if !raw_value.is_empty() {
values.insert(raw_value.as_bytes());
}
format!("{raw_key}={FILTERED_PLACEHOLDER_URLENCODED}")
})
.collect();
changed.then(|| masked.join("&"))
}
fn key_is_sensitive(key: &str, filter: &ParameterFilter) -> bool {
key_segments(key)
.iter()
.any(|segment| filter.matches_key(segment))
}
fn key_segments(key: &str) -> Vec<String> {
if let Some((head, rest)) = key.split_once('[') {
let mut parts = vec![head.to_owned()];
for segment in rest.split('[') {
let segment = segment.trim_end_matches(']');
if !segment.is_empty() {
parts.push(segment.to_owned());
}
}
parts
} else if key.contains('.') {
key.split('.')
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
.collect()
} else {
vec![key.to_owned()]
}
}
const UNPARSEABLE_BODY_KEY: &str = "body:<unparseable>";
const UNPARSEABLE_JSON_NOTE: &str = "request body declared a JSON content type but did not parse as JSON; it was masked out of \
the capsule rather than copied verbatim, because there are no keys to redact on";
const MULTIPART_BODY_NOTE: &str = "request body declared a multipart content type, which this slice does not parse; it was \
masked out of the capsule rather than copied verbatim, because its fields (and any uploaded \
file) cannot be redacted without parsing them";
const UNPARSEABLE_FORM_NOTE: &str = "request body declared a urlencoded form but did not parse as one; it was masked out of the \
capsule rather than copied verbatim, because there are no keys to redact on";
fn redact_body(
headers: &axum::http::HeaderMap,
raw_body: &CapturedBody,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
notes: &mut Vec<String>,
) -> CapsuleBody {
let bytes = match raw_body {
CapturedBody::Absent => return CapsuleBody::Absent,
CapturedBody::Skipped { declared_len } => {
return CapsuleBody::Skipped {
declared_len: *declared_len,
};
}
CapturedBody::Buffered(bytes) => bytes,
};
if bytes.is_empty() {
return CapsuleBody::Absent;
}
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if content_type.contains("json") {
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return unparseable_body(bytes, UNPARSEABLE_JSON_NOTE, values, keys, notes);
};
let keys_before = keys.len();
let scrubbed = scrub_value(&parsed, filter, "body", values, keys);
if keys.len() > keys_before {
retain_raw_json_string_spellings(bytes, values);
}
if keys.len() == keys_before {
return std::str::from_utf8(bytes).map_or(CapsuleBody::Absent, |text| {
CapsuleBody::Text(text.to_owned())
});
}
return serde_json::to_string(&scrubbed).map_or(CapsuleBody::Absent, CapsuleBody::Text);
}
if content_type.contains("application/x-www-form-urlencoded") {
let Some(text) = form_text(bytes) else {
return unparseable_body(bytes, UNPARSEABLE_FORM_NOTE, values, keys, notes);
};
return CapsuleBody::Text(
mask_raw_urlencoded(text, "body", filter, values, keys)
.unwrap_or_else(|| text.to_owned()),
);
}
if content_type.starts_with("multipart/") || content_type.contains("multipart/form-data") {
return unparseable_body(bytes, MULTIPART_BODY_NOTE, values, keys, notes);
}
std::str::from_utf8(bytes).map_or_else(
|_| CapsuleBody::Base64(STANDARD.encode(bytes)),
|text| CapsuleBody::Text(text.to_owned()),
)
}
fn unparseable_body(
bytes: &[u8],
note: &'static str,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
notes: &mut Vec<String>,
) -> CapsuleBody {
keys.insert(UNPARSEABLE_BODY_KEY.to_owned());
notes.push(note.to_owned());
if let Ok(text) = std::str::from_utf8(bytes) {
values.insert(text.as_bytes());
record_string_literal_values(text, values);
}
CapsuleBody::Skipped {
declared_len: Some(bytes.len()),
}
}
fn form_text(bytes: &[u8]) -> Option<&str> {
let text = std::str::from_utf8(bytes).ok()?;
let mut pairs = 0usize;
for segment in text.split('&') {
if segment.is_empty() {
continue;
}
let (key, _) = segment.split_once('=')?;
if key.is_empty() || key.contains(is_not_form_key_char) {
return None;
}
pairs = pairs.saturating_add(1);
}
if pairs == 0 {
return None;
}
Some(text)
}
const fn is_not_form_key_char(c: char) -> bool {
c.is_ascii_control()
|| c.is_whitespace()
|| matches!(c, '{' | '}' | '"' | '\'' | '<' | '>' | '\\')
}
fn scrub_value(
value: &serde_json::Value,
filter: &ParameterFilter,
path: &str,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (key, child) in map {
let child_path = format!("{path}.{key}");
if filter.matches_key(key) {
record_masked_value(child, values);
keys.insert(child_path);
out.insert(
key.clone(),
serde_json::Value::String(FILTERED_PLACEHOLDER.to_owned()),
);
} else {
out.insert(
key.clone(),
scrub_value(child, filter, &child_path, values, keys),
);
}
}
serde_json::Value::Object(out)
}
serde_json::Value::Array(items) => serde_json::Value::Array(
items
.iter()
.map(|item| scrub_value(item, filter, path, values, keys))
.collect(),
),
other => other.clone(),
}
}
fn record_masked_value(value: &serde_json::Value, values: &mut RedactedValues) {
match value {
serde_json::Value::String(text) => values.insert(text.as_bytes()),
serde_json::Value::Null => {}
serde_json::Value::Object(map) => {
values.insert(value.to_string().as_bytes());
for child in map.values() {
record_masked_value(child, values);
}
}
serde_json::Value::Array(items) => {
values.insert(value.to_string().as_bytes());
for child in items {
record_masked_value(child, values);
}
}
other => values.insert(other.to_string().as_bytes()),
}
}
fn record_string_literal_values(text: &str, values: &mut RedactedValues) {
let mut rest = text;
while let Some(start) = rest.find('"') {
let Some(after_quote) = rest.get(start.saturating_add(1)..) else {
return;
};
let mut end = None;
let mut escape = false;
for (index, c) in after_quote.char_indices() {
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
end = Some(index);
break;
}
}
let Some(end) = end else {
if !after_quote.is_empty() {
values.insert(after_quote.as_bytes());
}
return;
};
let Some(inner) = after_quote.get(..end) else {
return;
};
let Some(after_literal) = after_quote.get(end.saturating_add(1)..) else {
return;
};
let is_key = after_literal.trim_start().starts_with(':');
if !is_key && !inner.is_empty() {
let literal = format!("\"{inner}\"");
if let Ok(decoded) = serde_json::from_str::<String>(&literal) {
values.insert(decoded.as_bytes());
if inner.contains('\\') {
values.insert(inner.as_bytes());
}
} else {
values.insert(inner.as_bytes());
}
}
rest = after_literal;
}
}
fn retain_raw_json_string_spellings(bytes: &[u8], values: &mut RedactedValues) {
let Ok(text) = std::str::from_utf8(bytes) else {
return;
};
let mut rest = text;
while let Some(start) = rest.find('"') {
let Some(after_quote) = rest.get(start.saturating_add(1)..) else {
return;
};
let mut end = None;
let mut escape = false;
for (index, c) in after_quote.char_indices() {
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
end = Some(index);
break;
}
}
let Some(end) = end else {
return;
};
let Some(inner) = after_quote.get(..end) else {
return;
};
if inner.contains('\\') {
let literal = format!("\"{inner}\"");
if let Ok(decoded) = serde_json::from_str::<String>(&literal)
&& values.contains(decoded.as_bytes())
{
values.insert(inner.as_bytes());
}
}
let Some(next) = after_quote.get(end.saturating_add(1)..) else {
return;
};
rest = next;
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{Request, header};
fn filter_with(extra: &[&str]) -> ParameterFilter {
let extra: Vec<String> = extra.iter().map(|k| (*k).to_owned()).collect();
ParameterFilter::new(&extra, &[])
}
#[allow(
clippy::needless_pass_by_value,
reason = "fixture bodies read better handed over than borrowed at every call site"
)]
fn redact(
builder: axum::http::request::Builder,
body: CapturedBody,
filter: &ParameterFilter,
) -> (CapsuleRequest, RedactedValues) {
let (request, values, _) = redact_with_notes(builder, body, filter);
(request, values)
}
#[allow(
clippy::needless_pass_by_value,
reason = "fixture bodies read better handed over than borrowed at every call site"
)]
fn redact_with_notes(
builder: axum::http::request::Builder,
body: CapturedBody,
filter: &ParameterFilter,
) -> (CapsuleRequest, RedactedValues, Vec<String>) {
let request = builder.body(()).expect("test request builds");
let (parts, ()) = request.into_parts();
let raw = RawRequest {
method: parts.method.as_str().to_owned(),
uri: parts.uri,
version: parts.version,
headers: parts.headers,
route: None,
};
redact_request(&raw, &body, filter)
}
fn header_value<'a>(request: &'a CapsuleRequest, name: &str) -> &'a str {
request
.headers
.iter()
.find(|(key, _)| key == name)
.map_or_else(
|| panic!("header {name} must be present in the capsule"),
|(_, value)| value.as_str(),
)
}
#[test]
fn a_multipart_body_is_never_copied_into_the_capsule() {
let body = "--X\r\nContent-Disposition: form-data; name=\"password\"\r\n\r\n\
hunter2-in-the-clear\r\n--X--\r\n";
let (request, _values, notes) = redact_with_notes(
Request::post("/upload")
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=X"),
CapturedBody::Buffered(Bytes::from_static(body.as_bytes())),
&filter_with(&[]),
);
match &request.body {
CapsuleBody::Skipped { declared_len } => {
assert_eq!(*declared_len, Some(body.len()));
}
other => panic!("a multipart body must be skipped, got {other:?}"),
}
let rendered = serde_json::to_string(&request).expect("request serializes");
assert!(
!rendered.contains("hunter2-in-the-clear") && !rendered.contains("password"),
"no part of a multipart body may reach the capsule: {rendered}"
);
assert!(
notes.iter().any(|note| note.contains("multipart")),
"the capsule must say why the body is missing, got {notes:?}"
);
}
#[test]
fn a_short_masked_value_still_masks_its_bind() {
let (_request, values) = redact(
Request::post("/pay").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(b"{\"cvv\":\"123\",\"amount\":10}")),
&filter_with(&["cvv"]),
);
let mut binds = vec![
BindValue::Value(b"123".to_vec()),
BindValue::Value(b"10".to_vec()),
];
mask_binds(&mut binds, &values);
assert_eq!(
binds.first(),
Some(&BindValue::Masked),
"a short value the filter removed must still be masked out of the binds"
);
assert_eq!(
binds.get(1),
Some(&BindValue::Value(b"10".to_vec())),
"unrelated binds are untouched"
);
assert_eq!(
mask_echoes("the 123rd attempt", &values),
"the 123rd attempt",
"substring masking keeps its length floor"
);
}
#[test]
fn proxy_authorization_is_masked_unconditionally() {
let (request, values) = redact(
Request::get("/x").header("proxy-authorization", "Basic cHJveHk6c2VjcmV0cGFzcw=="),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(
header_value(&request, "proxy-authorization"),
FILTERED_PLACEHOLDER,
"a standard credential header must not depend on app config to be masked"
);
assert!(
values.contains(b"Basic cHJveHk6c2VjcmV0cGFzcw=="),
"the masked value must join the echo set"
);
}
#[test]
fn credential_components_join_the_echo_set() {
let (_request, values) = redact(
Request::get("/")
.header(header::AUTHORIZATION, "Bearer hunter2secret")
.header(header::COOKIE, "session=sess-abcdef; theme=dark"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"Bearer hunter2secret"),
"the whole header value is still retained"
);
assert!(
values.contains(b"hunter2secret"),
"the token after the auth scheme must be retained on its own"
);
assert!(
values.contains(b"sess-abcdef"),
"each cookie value must be retained on its own"
);
assert!(
!values.contains(b"session"),
"cookie names are ordinary words and must stay out of the echo set"
);
assert_eq!(
mask_echoes("darkness check failed", &values),
"darkness check failed",
"a `theme=dark` cookie must not shred words that merely contain it"
);
assert_eq!(
mask_echoes("theme dark rejected", &values),
format!("theme {FILTERED_PLACEHOLDER} rejected"),
"it is still masked where it stands as a whole token"
);
let (_request, set_cookie) = redact(
Request::get("/").header("set-cookie", "session=abc-secret; Path=/; Max-Age=0"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
set_cookie.contains(b"abc-secret"),
"the cookie value counts"
);
assert!(!set_cookie.contains(b"/"), "`Path=/` is an attribute");
assert!(!set_cookie.contains(b"0"), "`Max-Age=0` is an attribute");
assert_eq!(
mask_echoes("failed at / with status 0", &set_cookie),
"failed at / with status 0",
"attribute values must not rewrite unrelated outcome text"
);
let (_request, basic) = redact(
Request::get("/").header(header::AUTHORIZATION, "Basic YWxpY2U6aHVudGVyMjpwYXNz"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
basic.contains(b"YWxpY2U6aHVudGVyMjpwYXNz"),
"the Base64 credential is still retained"
);
assert!(
basic.contains(b"alice:hunter2:pass"),
"the decoded pair must be retained"
);
assert!(
basic.contains(b"hunter2:pass"),
"the password must be retained on its own, splitting on the first colon only"
);
assert!(
!basic.contains(b"alice"),
"the username is an ordinary word, like a cookie name"
);
let (_request, latin1) = redact(
Request::get("/").header(header::AUTHORIZATION, "Basic /3VzZXI6aHVudGVyMg=="),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
latin1.contains(b"hunter2"),
"a valid password must survive a username that is not UTF-8"
);
assert!(
latin1.contains(b"\xffuser:hunter2".as_slice()),
"the decoded pair is retained as bytes, not as text"
);
let (_request, not_basic) = redact(
Request::get("/").header(header::AUTHORIZATION, "Basic not-base64!!"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
not_basic.contains(b"not-base64!!"),
"the token still counts"
);
assert!(is_auth_scheme("Negotiate") && is_auth_scheme("AWS4-HMAC-SHA256"));
assert!(
!is_auth_scheme("session=abc;"),
"a cookie line is not an auth scheme"
);
let masked = mask_echoes("token hunter2secret was rejected", &values);
assert!(!masked.contains("hunter2secret"), "{masked}");
}
#[test]
fn auth_param_values_are_masked_only_as_whole_tokens() {
let (_request, sigv4) = redact(
Request::get("/").header(
header::AUTHORIZATION,
"AWS4-HMAC-SHA256 Credential=AKIA/20260815/us-east-1/s3/aws4_request, \
SignedHeaders=host;x-amz-date, Signature=abc123deadbeef",
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
sigv4.contains(b"abc123deadbeef"),
"the signature a handler extracts must be retained on its own"
);
assert_eq!(
mask_echoes("signature abc123deadbeef rejected", &sigv4),
format!("signature {FILTERED_PLACEHOLDER} rejected"),
"an auth-param value quoted back must still be masked"
);
let (_request, digest) = redact(
Request::get("/").header(
header::AUTHORIZATION,
r#"Digest username="alice", qop=auth, response="deadbeef""#,
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
digest.contains(b"deadbeef"),
"the digest response is retained"
);
assert_eq!(
mask_echoes("authentication failed during reauthorization", &digest),
"authentication failed during reauthorization",
"`qop=auth` must not shred every later word containing `auth`"
);
assert_eq!(
mask_echoes("scheme auth rejected", &digest),
format!("scheme {FILTERED_PLACEHOLDER} rejected"),
"it is still masked where it stands as a whole token"
);
assert_eq!(
mask_echoes("auth-error and auth_error raised", &digest),
"auth-error and auth_error raised",
"identifier punctuation does not make a value stand alone"
);
assert_eq!(
mask_echoes("api.auth.error raised", &digest),
"api.auth.error raised",
"a dot between alphanumerics joins the name"
);
assert_eq!(
mask_echoes("scheme was auth.", &digest),
format!("scheme was {FILTERED_PLACEHOLDER}."),
"a sentence-ending dot must not leave the secret unmasked"
);
}
#[test]
fn quoted_auth_param_values_survive_commas_and_escapes() {
let (_request, values) = redact(
Request::get("/").header(
header::AUTHORIZATION,
r#"Digest response="abc,def", opaque="gh\"ij", qop=auth"#,
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"abc,def"),
"a comma inside a quoted value does not end the param"
);
assert!(
!values.contains(b"abc"),
"the fragment before the comma is not what the handler holds"
);
assert!(
values.contains(br#"gh"ij"#),
"a quoted-pair is resolved to the character the handler sees"
);
assert!(
values.contains(b"auth"),
"an unquoted param after a quoted one is still found"
);
let (_request, bounded) = redact(
Request::get("/").header(header::AUTHORIZATION, r#"Digest response="\"hunter2\"""#),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
bounded.contains(br#""hunter2""#),
"literal quotes at the value's boundary are part of the value"
);
}
#[test]
fn token68_padding_is_not_read_as_an_auth_param() {
let (_request, values) = redact(
Request::get("/").header(header::AUTHORIZATION, "Basic dTpwdw=="),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"dTpwdw=="),
"the credential itself is still retained"
);
assert!(
!values.contains(b"="),
"padding must not become a secret of its own"
);
assert_eq!(
mask_echoes("x = y", &values),
"x = y",
"an invented `=` secret would rewrite ordinary text"
);
}
#[test]
fn a_direct_insert_outranks_the_whole_token_classification() {
for direct_first in [true, false] {
let mut values = RedactedValues::default();
if direct_first {
values.insert(b"hunter2");
values.insert_whole_token_only(b"hunter2");
} else {
values.insert_whole_token_only(b"hunter2");
values.insert(b"hunter2");
}
assert_eq!(
mask_echoes("token hunter2suffix rejected", &values),
format!("token {FILTERED_PLACEHOLDER}suffix rejected"),
"a directly captured secret is masked even mid-token (direct_first: {direct_first})"
);
}
}
#[test]
fn masking_does_not_rewrite_the_placeholders_it_just_wrote() {
let mut values = RedactedValues::default();
values.insert(b"hunter2");
values.insert(b"FILTER");
let masked = mask_echoes("login hunter2 failed", &values);
assert_eq!(
masked,
format!("login {FILTERED_PLACEHOLDER} failed"),
"the placeholder written for one secret must not be rewritten by another"
);
}
#[test]
fn component_parsing_is_limited_to_headers_whose_syntax_is_known() {
let (_request, values) = redact(
Request::get("/").header("password", "not valid"),
CapturedBody::Absent,
&filter_with(&["password"]),
);
assert!(
values.contains(b"not valid"),
"the whole value is still retained, as for any masked header"
);
assert!(
!values.contains(b"valid"),
"a custom header's value must not be split as though it were `Authorization`"
);
assert_eq!(
mask_echoes("the token was invalid", &values),
"the token was invalid",
"an invented component would have shredded this"
);
}
#[test]
fn authorization_and_cookie_headers_are_masked() {
let (request, values) = redact(
Request::get("/x")
.header(header::AUTHORIZATION, "Bearer super-secret-token")
.header(header::COOKIE, "session=abcdef123456")
.header(header::ACCEPT, "application/json"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(
header_value(&request, "authorization"),
FILTERED_PLACEHOLDER
);
assert_eq!(header_value(&request, "cookie"), FILTERED_PLACEHOLDER);
assert_eq!(
header_value(&request, "accept"),
"application/json",
"non-sensitive headers must survive verbatim"
);
assert!(
request
.redacted_keys
.contains(&"header:authorization".to_owned()),
"redacted_keys must name what was masked, got {:?}",
request.redacted_keys
);
assert!(
values.contains(b"Bearer super-secret-token"),
"the pre-mask header value must be retained for bind masking"
);
}
#[test]
fn set_cookie_and_configured_sensitive_params_are_masked() {
let (request, _) = redact(
Request::get("/x")
.header("set-cookie", "a=b")
.header("x-tenant-pin", "4321"),
CapturedBody::Absent,
&filter_with(&["x-tenant-pin"]),
);
assert_eq!(header_value(&request, "set-cookie"), FILTERED_PLACEHOLDER);
assert_eq!(
header_value(&request, "x-tenant-pin"),
FILTERED_PLACEHOLDER,
"a key added via [log] filter_parameters must be masked here too"
);
}
#[test]
fn encrypted_column_names_are_masked() {
let filter = filter_with(&["ssn_encrypted"]);
let (request, _) = redact(
Request::post("/x")
.header(header::CONTENT_TYPE, "application/json")
.header("x-ignored", "1"),
CapturedBody::Buffered(Bytes::from_static(
br#"{"ssn_encrypted":"123-45-6789","name":"Ada"}"#,
)),
&filter,
);
let CapsuleBody::Text(body) = &request.body else {
panic!(
"a JSON body must be captured as text, got {:?}",
request.body
);
};
assert!(
body.contains(FILTERED_PLACEHOLDER),
"an encrypted column name must be masked in the body, got {body}"
);
assert!(!body.contains("123-45-6789"), "the value must be gone");
assert!(body.contains("Ada"), "non-sensitive fields must survive");
}
#[test]
fn encoded_query_value_forms_join_the_echo_set() {
let (request, values) = redact(
Request::get("/callback?token=a%2Fb%2Bc&page=2"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
!request.uri.contains("a%2Fb"),
"the encoded value must be masked out of the capsule uri, got {}",
request.uri
);
assert!(
values.contains(b"a/b+c"),
"the decoded value must be in the echo set"
);
assert!(
values.contains(b"a%2Fb%2Bc"),
"the on-the-wire encoded value must be in the echo set too — an error \
that echoes the raw request target carries this spelling"
);
assert_eq!(
mask_echoes("failed on /callback?token=a%2Fb%2Bc", &values),
format!("failed on /callback?token={FILTERED_PLACEHOLDER}"),
"an echoed raw request target must scrub"
);
}
#[test]
fn encoded_form_body_value_forms_join_the_echo_set() {
let (request, values) = redact(
Request::post("/callback")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(b"token=a%2Fb%2Bc&page=2")),
&filter_with(&[]),
);
let CapsuleBody::Text(body) = &request.body else {
panic!(
"a form body must be captured as text, got {:?}",
request.body
);
};
assert!(
!body.contains("a%2Fb"),
"the encoded value must be masked out of the capsule body, got {body}"
);
assert!(
values.contains(b"a/b+c"),
"the decoded value must be in the echo set"
);
assert!(
values.contains(b"a%2Fb%2Bc"),
"the on-the-wire encoded value must be in the echo set too — an error \
that echoes the raw form body carries this spelling"
);
assert_eq!(
mask_echoes("bad form field token=a%2Fb%2Bc", &values),
format!("bad form field token={FILTERED_PLACEHOLDER}"),
"an echoed raw form body must scrub"
);
}
#[test]
fn non_utf8_header_bytes_are_preserved_not_placeholdered() {
let mut headers = axum::http::HeaderMap::new();
headers.append(
"x-meta",
axum::http::HeaderValue::from_static("plain-text-first"),
);
headers.append(
"x-meta",
axum::http::HeaderValue::from_bytes(&[0x61, 0xFF, 0x62]).expect("obs-text is legal"),
);
headers.insert(
"authorization",
axum::http::HeaderValue::from_bytes(&[0x73, 0xFF]).expect("obs-text is legal"),
);
let raw = RawRequest {
method: "GET".to_owned(),
uri: "/meta".parse().expect("uri"),
version: axum::http::Version::HTTP_11,
headers,
route: None,
};
let (request, _values, _notes) =
redact_request(&raw, &CapturedBody::Absent, &filter_with(&[]));
assert!(
!request.headers.iter().any(|(name, _)| name == "x-meta"),
"a name with any non-UTF-8 value moves wholesale out of the text list"
);
let meta_values: Vec<Vec<u8>> = request
.binary_headers
.iter()
.filter(|(name, _)| name == "x-meta")
.map(|(_, value)| STANDARD.decode(value).expect("valid base64"))
.collect();
assert_eq!(
meta_values,
vec![b"plain-text-first".to_vec(), vec![0x61, 0xFF, 0x62]],
"all of the name's values live in binary_headers, exact bytes, original order"
);
assert!(
request
.headers
.iter()
.any(|(name, value)| name == "authorization" && value == FILTERED_PLACEHOLDER),
"a sensitive header is masked regardless of its encoding"
);
assert!(
!request
.binary_headers
.iter()
.any(|(name, _)| name == "authorization"),
"a sensitive header's bytes must never survive into binary_headers"
);
}
#[test]
fn leaf_values_of_a_masked_json_container_join_the_echo_set() {
let raw = br#"{"secret":{"value":"hunter2secret","attempts":42},"keep":"public"}"#;
let (request, values) = redact(
Request::post("/hook").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(raw)),
&filter_with(&[]),
);
let CapsuleBody::Text(body) = &request.body else {
panic!(
"a JSON body must be captured as text, got {:?}",
request.body
);
};
assert!(
!body.contains("hunter2secret"),
"the nested value must be masked out of the body, got {body}"
);
assert!(body.contains("public"), "unmatched fields must survive");
assert!(
values.contains(b"hunter2secret"),
"a string leaf under a masked container must be in the echo set"
);
assert!(
values.contains(b"42"),
"a numeric leaf under a masked container must be in the echo set"
);
assert!(
values.contains(br#"{"value":"hunter2secret","attempts":42}"#)
|| values.contains(br#"{"attempts":42,"value":"hunter2secret"}"#),
"the container's own serialization stays in the set too"
);
assert_eq!(
mask_echoes("could not verify hunter2secret", &values),
format!("could not verify {FILTERED_PLACEHOLDER}"),
"an error echoing an extracted leaf must scrub"
);
}
#[test]
fn escaped_json_value_spellings_join_the_echo_set() {
let raw = br#"{"token":"line\nbreak!"}"#;
let (request, values) = redact(
Request::post("/hook").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(raw)),
&filter_with(&[]),
);
let CapsuleBody::Text(body) = &request.body else {
panic!(
"a JSON body must be captured as text, got {:?}",
request.body
);
};
assert!(
!body.contains("break"),
"the value must be masked out of the body, got {body}"
);
assert!(
values.contains(b"line\nbreak!"),
"the decoded spelling must be in the echo set"
);
assert!(
values.contains(br"line\nbreak!"),
"the on-wire escaped spelling must be in the echo set too"
);
assert_eq!(
mask_echoes(r#"rejected body {"token":"line\nbreak!"}"#, &values),
format!(r#"rejected body {{"token":"{FILTERED_PLACEHOLDER}"}}"#),
"an error echoing the raw body must scrub the escaped spelling"
);
}
#[test]
fn an_unredacted_json_body_keeps_its_exact_bytes() {
let raw = br#"{ "b": 1.50, "a": "x" }"#;
let (request, _values) = redact(
Request::post("/webhook").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(raw)),
&filter_with(&[]),
);
assert_eq!(
request.body,
CapsuleBody::Text(String::from_utf8(raw.to_vec()).expect("utf8")),
"a JSON body with nothing to mask must be preserved byte for byte"
);
}
#[test]
fn unredacted_query_pairs_keep_their_raw_spelling() {
let (request, _values) = redact(
Request::get("/hook?path=a%2fb&flag&q=1+2"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(
request.uri, "/hook?path=a%2fb&flag&q=1+2",
"an unredacted query must be preserved byte for byte"
);
let (request, values) = redact(
Request::get("/hook?path=a%2fb&token=s3cret&flag"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(
request.uri, "/hook?path=a%2fb&token=%5BFILTERED%5D&flag",
"only the matched pair may be rewritten"
);
assert!(values.contains(b"s3cret"));
}
#[test]
fn an_absolute_form_target_keeps_its_scheme_and_authority() {
let (request, _values) = redact(
Request::get("https://api.example/items?token=s3cret&keep=a%2f"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(
request.uri, "https://api.example/items?token=%5BFILTERED%5D&keep=a%2f",
"scheme and authority must survive query masking"
);
let (request, _values) = redact(
Request::get("https://api.example/items?keep=a%2f"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(request.uri, "https://api.example/items?keep=a%2f");
}
#[test]
fn unredacted_form_pairs_keep_their_raw_spelling() {
let (request, _values) = redact(
Request::post("/hook")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(b"path=a%2fb&q=1+2")),
&filter_with(&[]),
);
assert_eq!(
request.body,
CapsuleBody::Text("path=a%2fb&q=1+2".to_owned()),
"an unredacted form body must be preserved byte for byte"
);
let (request, values) = redact(
Request::post("/hook")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(b"path=a%2fb&token=s3cret")),
&filter_with(&[]),
);
assert_eq!(
request.body,
CapsuleBody::Text("path=a%2fb&token=%5BFILTERED%5D".to_owned()),
"only the matched form field may be rewritten"
);
assert!(values.contains(b"s3cret"));
}
#[test]
fn query_string_params_are_masked() {
let (request, values) = redact(
Request::get("/search?q=cats&api_key=abcdef123456&page=2"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
request.uri.contains("q=cats") && request.uri.contains("page=2"),
"harmless query parameters must survive, got {}",
request.uri
);
assert!(
!request.uri.contains("abcdef123456"),
"a sensitive query value must never be written to the capsule, got {}",
request.uri
);
assert!(
request.uri.contains("api_key=%5BFILTERED%5D")
|| request.uri.contains("api_key=[FILTERED]"),
"the masked parameter must remain present as a placeholder, got {}",
request.uri
);
assert!(values.contains(b"abcdef123456"));
}
#[test]
fn form_body_bracket_keys_are_masked() {
let (request, values) = redact(
Request::post("/users")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(
b"user%5Bemail%5D=ada%40example.com&user%5Bpassword%5D=hunter2secret",
)),
&filter_with(&[]),
);
let CapsuleBody::Text(body) = &request.body else {
panic!(
"a form body must be captured as text, got {:?}",
request.body
);
};
assert!(
!body.contains("hunter2secret"),
"a bracket-notation password must be masked, got {body}"
);
assert!(
body.contains("ada%40example.com") || body.contains("ada@example.com"),
"non-sensitive form fields must survive, got {body}"
);
assert!(
request
.redacted_keys
.iter()
.any(|key| key.contains("password")),
"redacted_keys must name the masked form key, got {:?}",
request.redacted_keys
);
assert!(values.contains(b"hunter2secret"));
}
#[test]
fn json_body_is_masked_recursively() {
let (request, values) = redact(
Request::post("/users").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(
br#"{"a":{"b":[{"token":"deep-secret-value"}]},"keep":"visible"}"#,
)),
&filter_with(&[]),
);
let CapsuleBody::Text(body) = &request.body else {
panic!(
"a JSON body must be captured as text, got {:?}",
request.body
);
};
assert!(
!body.contains("deep-secret-value"),
"redaction must recurse through objects and arrays, got {body}"
);
assert!(body.contains("visible"));
assert!(values.contains(b"deep-secret-value"));
}
#[test]
fn malformed_json_body_is_masked_not_copied_verbatim() {
let (request, values, notes) = redact_with_notes(
Request::post("/users").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(br#"{"password":"hunter2secret", oops"#)),
&filter_with(&[]),
);
assert_eq!(
request.body,
CapsuleBody::Skipped {
declared_len: Some(33)
},
"a body that declared JSON but did not parse must never be copied verbatim, got {:?}",
request.body
);
assert!(
values.contains(b"hunter2secret"),
"a skipped body must still seed the echo set, so an outcome quoting it is scrubbed"
);
assert!(
!values.contains(b"password"),
"field names stay out of the echo set — masking them would shred outcome prose"
);
assert!(
request
.redacted_keys
.contains(&UNPARSEABLE_BODY_KEY.to_owned()),
"redacted_keys must record that the body was masked, got {:?}",
request.redacted_keys
);
assert_eq!(notes, vec![UNPARSEABLE_JSON_NOTE.to_owned()]);
}
#[test]
fn truncated_json_prefix_is_masked() {
let (request, _, notes) = redact_with_notes(
Request::post("/users").header(header::CONTENT_TYPE, "application/json; charset=utf-8"),
CapturedBody::Buffered(Bytes::from_static(br#"{"token":"deep-secret-value","#)),
&filter_with(&[]),
);
assert!(
matches!(request.body, CapsuleBody::Skipped { .. }),
"a truncated JSON prefix must be masked, got {:?}",
request.body
);
assert_eq!(notes, vec![UNPARSEABLE_JSON_NOTE.to_owned()]);
}
#[test]
fn a_skipped_malformed_body_still_masks_echoed_values() {
let (request, values, _) = redact_with_notes(
Request::post("/users").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(br#"{"password":"hunter2secret","#)),
&filter_with(&[]),
);
assert!(matches!(request.body, CapsuleBody::Skipped { .. }));
let masked = mask_echoes("could not store credential hunter2secret", &values);
assert!(
!masked.contains("hunter2secret"),
"an echoed body value must be masked out of the outcome: {masked}"
);
let masked = mask_echoes(r#"bad payload: {"password":"hunter2secret","#, &values);
assert!(
!masked.contains("hunter2secret"),
"an echoed raw body must be masked out of the outcome: {masked}"
);
assert_eq!(
mask_echoes("password rejected", &values),
"password rejected"
);
}
#[test]
fn a_body_truncated_inside_a_literal_still_masks_the_remainder() {
let (_, values, _) = redact_with_notes(
Request::post("/users").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(br#"{"token":"deep-secret-value"#)),
&filter_with(&[]),
);
let masked = mask_echoes("refused: deep-secret-value", &values);
assert!(
!masked.contains("deep-secret-value"),
"the unterminated literal's remainder must be masked: {masked}"
);
}
#[test]
fn body_that_is_not_a_form_under_a_form_content_type_is_masked() {
let (request, _, notes) = redact_with_notes(
Request::post("/users")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(br#"{"password":"hunter2secret"}"#)),
&filter_with(&[]),
);
assert!(
matches!(request.body, CapsuleBody::Skipped { .. }),
"a body that declared a form but did not parse as one must be masked, got {:?}",
request.body
);
assert_eq!(notes, vec![UNPARSEABLE_FORM_NOTE.to_owned()]);
}
#[test]
fn valid_structured_bodies_still_parse_without_a_note() {
let (request, values, notes) = redact_with_notes(
Request::post("/users").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Buffered(Bytes::from_static(
br#"{"password":"hunter2secret","keep":"visible"}"#,
)),
&filter_with(&[]),
);
let CapsuleBody::Text(body) = &request.body else {
panic!("valid JSON must still be parsed and scrubbed, got {request:?}");
};
assert!(!body.contains("hunter2secret") && body.contains("visible"));
assert!(values.contains(b"hunter2secret"));
assert!(
notes.is_empty(),
"a parsed body needs no note, got {notes:?}"
);
let (request, _, notes) = redact_with_notes(
Request::post("/users")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(b"email=ada%40example.com&flag=&page=2")),
&filter_with(&[]),
);
assert!(
matches!(&request.body, CapsuleBody::Text(body) if body.contains("page=2")),
"an ordinary form body — empty values included — must still parse, got {:?}",
request.body
);
assert!(
notes.is_empty(),
"a parsed body needs no note, got {notes:?}"
);
}
#[test]
fn unstructured_bodies_are_unaffected() {
let (request, _, notes) = redact_with_notes(
Request::post("/notes").header(header::CONTENT_TYPE, "text/plain"),
CapturedBody::Buffered(Bytes::from_static(b"{not json, not a form")),
&filter_with(&[]),
);
assert_eq!(
request.body,
CapsuleBody::Text("{not json, not a form".to_owned())
);
assert!(notes.is_empty());
}
#[test]
fn oversized_body_is_skipped_not_consumed() {
let (request, _) = redact(
Request::post("/upload").header(header::CONTENT_TYPE, "application/json"),
CapturedBody::Skipped {
declared_len: Some(9_000_000),
},
&filter_with(&[]),
);
assert_eq!(
request.body,
CapsuleBody::Skipped {
declared_len: Some(9_000_000)
},
"an oversized body must be recorded as skipped, never partially copied"
);
}
#[test]
fn binary_body_is_base64_encoded() {
let (request, _) = redact(
Request::post("/upload").header(header::CONTENT_TYPE, "application/octet-stream"),
CapturedBody::Buffered(Bytes::from_static(&[0xFF, 0xFE, 0x00, 0x01])),
&filter_with(&[]),
);
let CapsuleBody::Base64(encoded) = &request.body else {
panic!(
"a non-UTF-8 body must be base64-encoded, got {:?}",
request.body
);
};
assert_eq!(encoded, "//4AAQ==");
}
#[test]
fn outcome_text_echoing_a_redacted_value_is_masked() {
let (_, values) = redact(
Request::post("/users")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(b"email=ada&password=hunter2secret")),
&filter_with(&[]),
);
let masked = mask_echoes("could not store password=hunter2secret for ada", &values);
assert!(
!masked.contains("hunter2secret"),
"a value redaction removed must not reappear in the outcome, got {masked}"
);
assert!(
masked.contains(FILTERED_PLACEHOLDER),
"the echo must be replaced by the placeholder, got {masked}"
);
assert!(
masked.contains("for ada"),
"the rest of the message must survive, got {masked}"
);
}
#[test]
fn mask_echoes_prefers_the_longest_match() {
let mut values = RedactedValues::default();
values.insert(b"hunter2secret");
values.insert(b"hunter2");
let masked = mask_echoes("tried hunter2secret twice", &values);
assert_eq!(masked, format!("tried {FILTERED_PLACEHOLDER} twice"));
}
#[test]
fn mask_echoes_masks_short_values_only_as_whole_tokens() {
let mut values = RedactedValues::default();
values.insert(b"123");
assert_eq!(
mask_echoes("CVV 123 rejected", &values),
format!("CVV {FILTERED_PLACEHOLDER} rejected"),
"a short secret quoted in the failure must be masked"
);
assert_eq!(
mask_echoes("cvv=123&ok=1", &values),
format!("cvv={FILTERED_PLACEHOLDER}&ok=1"),
"punctuation delimits a token just as whitespace does"
);
assert_eq!(
mask_echoes("request 1234 took 5123ms at 12:31:23", &values),
"request 1234 took 5123ms at 12:31:23",
"a short value inside a longer run is not a secret occurrence"
);
assert_eq!(
mask_echoes("request 123123 failed", &values),
"request 123123 failed",
"adjacent occurrences form one alphanumeric run, not whole tokens"
);
assert_eq!(
mask_echoes("123", &values),
FILTERED_PLACEHOLDER,
"a value that is the entire text is a whole token"
);
}
#[test]
fn mask_echoes_is_a_no_op_without_redactions() {
let values = RedactedValues::default();
assert_eq!(mask_echoes("nothing to hide", &values), "nothing to hide");
}
#[test]
fn bind_matching_a_redacted_value_is_masked() {
let mut values = RedactedValues::default();
values.insert(b"hunter2secret");
let mut binds = vec![
BindValue::Value(b"hunter2secret".to_vec()),
BindValue::Value(b"ada@example.com".to_vec()),
BindValue::Null,
];
mask_binds(&mut binds, &values);
assert_eq!(
binds,
vec![
BindValue::Masked,
BindValue::Value(b"ada@example.com".to_vec()),
BindValue::Null,
],
"a bind echoing a redacted value must be masked; others must survive"
);
}
}