#![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::borrow::Cow;
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;
const MAX_COOKIE_PAIRS: usize = 256;
#[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 at_placeholder = rest.starts_with(FILTERED_PLACEHOLDER);
let matched = needles.iter().find(|(needle, whole_token_only)| {
rest.starts_with(*needle)
&& (!at_placeholder
|| (needle.len() > FILTERED_PLACEHOLDER.len()
&& needle.starts_with(FILTERED_PLACEHOLDER)))
&& (!*whole_token_only || stands_alone(text, cursor, needle, rest))
});
if at_placeholder && matched.is_none() {
masked.push_str(FILTERED_PLACEHOLDER);
cursor = cursor.saturating_add(FILTERED_PLACEHOLDER.len());
continue;
}
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 const LITERAL_PLACEHOLDER_SUFFIX: &str = ":<literal placeholder>";
#[must_use]
pub fn literal_placeholder_locations(
effects: &crate::capsule::schema::CapsuleEffects,
) -> Vec<String> {
compared_fields_where(effects, &|text: &str| {
text.contains(FILTERED_PLACEHOLDER) || text.contains("%5BFILTERED%5D")
})
}
#[must_use]
pub fn adjacent_placeholder_locations(
effects: &crate::capsule::schema::CapsuleEffects,
) -> Vec<String> {
compared_fields_where(effects, &crate::capsule::effects::has_adjacent_placeholders)
}
fn compared_fields_where(
effects: &crate::capsule::schema::CapsuleEffects,
holds: &dyn Fn(&str) -> bool,
) -> Vec<String> {
use crate::capsule::schema::CacheEffect;
fn json_holds(value: &serde_json::Value, holds: &dyn Fn(&str) -> bool) -> bool {
match value {
serde_json::Value::String(text) => holds(text),
serde_json::Value::Array(items) => items.iter().any(|item| json_holds(item, holds)),
serde_json::Value::Object(fields) => {
fields.values().any(|field| json_holds(field, holds))
}
_ => false,
}
}
let body_holds = |body: &CapsuleBody| -> bool {
match body {
CapsuleBody::Text(text) => holds(text),
CapsuleBody::Base64(encoded) => STANDARD
.decode(encoded.as_bytes())
.is_ok_and(|bytes| std::str::from_utf8(&bytes).is_ok_and(holds)),
CapsuleBody::Absent | CapsuleBody::Skipped { .. } => false,
}
};
let headers_hold = |headers: &[(String, String)]| headers.iter().any(|(_, value)| holds(value));
let mut found = Vec::new();
for (index, exchange) in effects.http.iter().enumerate() {
if holds(&exchange.url) {
found.push(format!("http[{index}].url"));
}
if headers_hold(&exchange.request_headers) {
found.push(format!("http[{index}].request_header"));
}
if body_holds(&exchange.request_body) {
found.push(format!("http[{index}].request_body"));
}
}
for (index, job) in effects.jobs.iter().enumerate() {
if json_holds(&job.payload, holds) {
found.push(format!("job[{index}].payload"));
}
}
for (index, mail) in effects.mail.iter().enumerate() {
let text_fields = mail
.to
.iter()
.map(String::as_str)
.chain([mail.subject.as_str()])
.chain(mail.from.as_deref())
.chain(mail.reply_to.as_deref())
.chain(mail.list_unsubscribe.as_deref())
.chain(mail.attachments.iter().map(|a| a.filename.as_str()));
if text_fields.into_iter().any(holds)
|| body_holds(&mail.body)
|| body_holds(&mail.alternate_body)
|| headers_hold(&mail.extra_headers)
{
found.push(format!("mail[{index}]"));
}
}
for (index, entry) in effects.cache.iter().enumerate() {
let key_holds = holds(entry.key());
let value_holds = match entry {
CacheEffect::Insert { value, .. } => STANDARD
.decode(value.as_bytes())
.is_ok_and(|bytes| std::str::from_utf8(&bytes).is_ok_and(holds)),
_ => false,
};
if key_holds || value_holds {
found.push(format!("cache[{index}]"));
}
}
found
}
#[allow(
clippy::too_many_lines,
reason = "one pass per seam, then one echo pass per seam; splitting them \
would hide the two-phase ordering the doc comment above explains"
)]
pub fn redact_effects(
effects: &mut crate::capsule::schema::CapsuleEffects,
job: Option<&mut crate::capsule::schema::CapsuleJob>,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
) {
let mut job = job;
if let Some(job) = job.as_mut() {
job.payload = scrub_value(&job.payload, filter, "job_entry", values, keys);
}
for (index, exchange) in effects.http.iter_mut().enumerate() {
exchange.url = redact_url_query(
&exchange.url,
filter,
values,
keys,
&format!("http[{index}].url"),
);
if let Some(final_url) = exchange.final_url.as_ref() {
exchange.final_url = Some(redact_url_query(
final_url,
filter,
values,
keys,
&format!("http[{index}].final_url"),
));
}
let request_content_type = header_value(&exchange.request_headers, "content-type");
let response_content_type = header_value(&exchange.response_headers, "content-type");
redact_effect_headers(
&mut exchange.request_headers,
filter,
values,
keys,
&format!("http[{index}].request_header"),
);
redact_effect_headers(
&mut exchange.response_headers,
filter,
values,
keys,
&format!("http[{index}].response_header"),
);
exchange.request_body = redact_effect_body(
&exchange.request_body,
&request_content_type,
filter,
values,
keys,
&format!("http[{index}].request_body"),
);
exchange.response_body = redact_effect_body(
&exchange.response_body,
&response_content_type,
filter,
values,
keys,
&format!("http[{index}].response_body"),
);
}
for (index, job) in effects.jobs.iter_mut().enumerate() {
job.payload = scrub_value(&job.payload, filter, &format!("job[{index}]"), values, keys);
}
for (index, entry) in effects.cache.iter_mut().enumerate() {
match entry {
crate::capsule::schema::CacheEffect::Get { value, .. } => {
if let Some(encoded) = value.as_mut() {
*encoded = scrub_encoded_json(
encoded,
filter,
&format!("cache[{index}]"),
values,
keys,
);
}
}
crate::capsule::schema::CacheEffect::Insert { value, .. } => {
*value =
scrub_encoded_json(value, filter, &format!("cache[{index}]"), values, keys);
}
crate::capsule::schema::CacheEffect::Invalidate { .. }
| crate::capsule::schema::CacheEffect::InvalidateNamespace { .. }
| crate::capsule::schema::CacheEffect::Clear => {}
}
}
for (index, mail) in effects.mail.iter_mut().enumerate() {
mail.alternate_body = redact_effect_body(
&mail.alternate_body,
"text/html",
filter,
values,
keys,
&format!("mail[{index}].alternate_body"),
);
redact_effect_headers(
&mut mail.extra_headers,
filter,
values,
keys,
&format!("mail[{index}].header"),
);
mail.body = redact_effect_body(
&mail.body,
"text/plain",
filter,
values,
keys,
&format!("mail[{index}].body"),
);
}
for (index, exchange) in effects.http.iter_mut().enumerate() {
let (masked_url, host_masked) = mask_url_echoes(&exchange.url, values);
if host_masked {
keys.insert(format!("http[{index}].url:<host-echo>"));
}
exchange.url = masked_url;
if let Some(final_url) = exchange.final_url.as_mut() {
let (masked, _) = mask_url_echoes(final_url, values);
if masked != *final_url {
keys.insert(format!("http[{index}].final_url:<echo>"));
}
*final_url = masked;
}
mask_body_echoes(&mut exchange.request_body, values);
if mask_body_echoes(&mut exchange.response_body, values) {
keys.insert(format!("http[{index}].response_body:<echo>"));
}
for (_, value) in &mut exchange.request_headers {
*value = mask_echoes(value, values);
}
for (name, value) in &mut exchange.response_headers {
let masked = mask_echoes(value, values);
if masked != *value {
keys.insert(format!("http[{index}].response_header:{name}"));
}
*value = masked;
}
if let Some(error) = exchange.error.as_mut() {
let masked = mask_echoes(error, values);
if masked != *error {
keys.insert(format!("http[{index}].error:<echo>"));
}
*error = masked;
}
}
for (index, entry) in effects.cache.iter_mut().enumerate() {
match entry {
crate::capsule::schema::CacheEffect::Get { key, .. }
| crate::capsule::schema::CacheEffect::Insert { key, .. } => {
*key = mask_echoes(key, values);
}
crate::capsule::schema::CacheEffect::Invalidate { key, error }
| crate::capsule::schema::CacheEffect::InvalidateNamespace {
namespace: key,
error,
} => {
*key = mask_echoes(key, values);
if let Some(error) = error.as_mut() {
let masked = mask_echoes(&error.reason, values);
if masked != error.reason {
keys.insert(format!("cache[{index}].error:<echo>"));
}
error.reason = masked;
}
}
crate::capsule::schema::CacheEffect::Clear => {}
}
}
for (index, mail) in effects.mail.iter_mut().enumerate() {
mail.subject = mask_echoes(&mail.subject, values);
mask_body_echoes(&mut mail.body, values);
mask_body_echoes(&mut mail.alternate_body, values);
for recipient in &mut mail.to {
*recipient = mask_echoes(recipient, values);
}
if let Some(reply_to) = mail.reply_to.as_mut() {
*reply_to = mask_echoes(reply_to, values);
}
if let Some(unsubscribe) = mail.list_unsubscribe.as_mut() {
*unsubscribe = mask_echoes(unsubscribe, values);
}
for (_, value) in &mut mail.extra_headers {
*value = mask_echoes(value, values);
}
for attachment in &mut mail.attachments {
attachment.filename = mask_echoes(&attachment.filename, values);
}
if let Some(from) = mail.from.as_mut() {
*from = mask_echoes(from, values);
}
if let Some(error) = mail.error.as_mut() {
let masked = mask_echoes(error, values);
if masked != *error {
keys.insert(format!("mail[{index}].error:<echo>"));
}
*error = masked;
}
}
for (index, effect) in effects.jobs.iter_mut().enumerate() {
effect.payload = mask_json_echoes(&effect.payload, values);
if let Some(error) = effect.error.as_mut() {
let masked = mask_echoes(error, values);
if masked != *error {
keys.insert(format!("job[{index}].error:<echo>"));
}
*error = masked;
}
}
if let Some(job) = job.as_mut() {
let masked = mask_json_echoes(&job.payload, values);
if masked != job.payload {
keys.insert("job_entry.<echo>".to_owned());
}
job.payload = masked;
}
for (index, entry) in effects.cache.iter_mut().enumerate() {
match entry {
crate::capsule::schema::CacheEffect::Get { value, .. } => {
if let Some(encoded) = value.as_mut() {
let masked = mask_encoded_json_echoes(encoded, values);
if masked != *encoded {
keys.insert(format!("cache[{index}]:<echo>"));
}
*encoded = masked;
}
}
crate::capsule::schema::CacheEffect::Insert { value, .. } => {
*value = mask_encoded_json_echoes(value, values);
}
crate::capsule::schema::CacheEffect::Invalidate { .. }
| crate::capsule::schema::CacheEffect::InvalidateNamespace { .. }
| crate::capsule::schema::CacheEffect::Clear => {}
}
}
if let Some(tenant) = effects.tenant.as_mut()
&& let Some(id) = tenant.id.as_mut()
{
let masked = mask_echoes(id, values);
if masked != *id {
keys.insert("tenant.id".to_owned());
}
*id = masked;
}
}
fn mask_url_userinfo(
url: &str,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
location: &str,
) -> String {
let Some((head, userinfo, host)) = split_userinfo(url) else {
return url.to_owned();
};
keys.insert(format!("{location}:userinfo"));
record_userinfo_components(&userinfo, values);
format!("{head}{FILTERED_PLACEHOLDER}@{host}")
}
fn authority_bounds(url: &str) -> Option<(usize, usize)> {
let lead = url
.len()
.checked_sub(url.trim_start_matches(|c: char| c <= ' ').len())?;
let from_scheme = url.get(lead..)?;
let colon = from_scheme.find(':')?;
let scheme = from_scheme.get(..colon)?;
let mut chars = scheme.chars();
if !chars.next()?.is_ascii_alphabetic()
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return None;
}
let special = ["http", "https", "ws", "wss", "ftp"]
.iter()
.any(|name| scheme.eq_ignore_ascii_case(name));
let after_scheme = from_scheme.get(colon.checked_add(1)?..)?;
let slashes = after_scheme
.find(|c: char| !(c == '/' || (special && c == '\\')))
.unwrap_or(after_scheme.len());
if !special && slashes < 2 {
return None;
}
let start = lead
.checked_add(colon)?
.checked_add(1)?
.checked_add(slashes)?;
let rest = url.get(start..)?;
let len = rest
.find(|c: char| matches!(c, '/' | '?' | '#') || (special && c == '\\'))
.unwrap_or(rest.len());
Some((start, start.checked_add(len)?))
}
pub(crate) fn parser_view(url: &str) -> Cow<'_, str> {
if url.contains(['\t', '\n', '\r']) {
Cow::Owned(
url.chars()
.filter(|c| !matches!(c, '\t' | '\n' | '\r'))
.collect(),
)
} else {
Cow::Borrowed(url)
}
}
fn split_userinfo(url: &str) -> Option<(String, String, String)> {
let url = parser_view(url);
let (start, end) = authority_bounds(&url)?;
let authority = url.get(start..end)?;
let (userinfo, _) = authority.rsplit_once('@')?;
if userinfo.is_empty() {
return None;
}
let head = url.get(..start)?;
let host_and_tail = url.get(start.checked_add(userinfo.len())?.checked_add(1)?..)?;
Some((
head.to_owned(),
userinfo.to_owned(),
host_and_tail.to_owned(),
))
}
fn mask_url_echoes(url: &str, redacted: &RedactedValues) -> (String, bool) {
let host_masked = authority_bounds(url).is_some_and(|(start, end)| {
let authority = url.get(start..end).unwrap_or_default();
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
mask_echoes(host, redacted) != host
});
(mask_echoes(url, redacted), host_masked)
}
pub(crate) fn url_userinfo(url: &str) -> Option<String> {
split_userinfo(url).map(|(_, userinfo, _)| userinfo)
}
pub(crate) fn hide_url_userinfo(url: &str) -> String {
split_userinfo(url).map_or_else(
|| url.to_owned(),
|(head, _, host)| format!("{head}{FILTERED_PLACEHOLDER}@{host}"),
)
}
pub(crate) fn record_userinfo_components(userinfo: &str, values: &mut RedactedValues) {
let mut retain = |part: &str| {
if part.is_empty() {
return;
}
values.insert(part.as_bytes());
let decoded: Vec<u8> = percent_encoding::percent_decode_str(part).collect();
if decoded != part.as_bytes() {
values.insert(&decoded);
let lossy = String::from_utf8_lossy(&decoded);
if lossy.as_bytes() != decoded.as_slice() {
values.insert(lossy.as_bytes());
}
}
};
retain(userinfo);
let (user, password) = userinfo.split_once(':').unwrap_or((userinfo, ""));
retain(user);
retain(password);
if let Ok(parsed) = url::Url::parse(&format!("http://{userinfo}@host/")) {
retain(parsed.username());
retain(parsed.password().unwrap_or_default());
}
}
fn redact_url_query(
url: &str,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
location: &str,
) -> String {
let url = mask_url_userinfo(url, values, keys, location);
let url = url.as_str();
let Some(split) = url.find('?') else {
return url.to_owned();
};
let head = url.get(..split).unwrap_or_default();
let query = url.get(split.saturating_add(1)..).unwrap_or_default();
let (query, fragment) = query.find('#').map_or((query, ""), |hash| {
(
query.get(..hash).unwrap_or_default(),
query.get(hash..).unwrap_or_default(),
)
});
let Some(masked) = mask_raw_urlencoded(query, location, filter, values, keys) else {
return url.to_owned();
};
format!("{head}?{masked}{fragment}")
}
fn mask_json_echoes(value: &serde_json::Value, redacted: &RedactedValues) -> serde_json::Value {
match value {
serde_json::Value::String(text) => serde_json::Value::String(mask_echoes(text, redacted)),
serde_json::Value::Array(items) => serde_json::Value::Array(
items
.iter()
.map(|item| mask_json_echoes(item, redacted))
.collect(),
),
serde_json::Value::Object(map) => serde_json::Value::Object(
map.iter()
.map(|(key, child)| (key.clone(), mask_json_echoes(child, redacted)))
.collect(),
),
other => other.clone(),
}
}
fn mask_encoded_json_echoes(encoded: &str, redacted: &RedactedValues) -> String {
let Ok(bytes) = STANDARD.decode(encoded.as_bytes()) else {
return encoded.to_owned();
};
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return encoded.to_owned();
};
let masked = mask_json_echoes(&parsed, redacted);
if masked == parsed {
return encoded.to_owned();
}
serde_json::to_vec(&masked).map_or_else(|_| encoded.to_owned(), |bytes| STANDARD.encode(&bytes))
}
fn header_value(headers: &[(String, String)], name: &str) -> String {
headers
.iter()
.find(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.to_ascii_lowercase())
.unwrap_or_default()
}
fn redact_effect_headers(
headers: &mut [(String, String)],
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
location: &str,
) {
for (name, value) in headers.iter_mut() {
if header_is_sensitive(name, filter) || effect_header_is_sensitive(name) {
values.insert(value.as_bytes());
record_credential_components(name, value.as_bytes(), values);
keys.insert(format!("{location}:{name}"));
value.clear();
value.push_str(FILTERED_PLACEHOLDER);
}
}
}
const OUTBOUND_SENSITIVE_HEADERS: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"api-key",
"apikey",
"x-auth-token",
"x-access-token",
"x-amz-security-token",
"x-goog-api-key",
];
fn effect_header_is_sensitive(name: &str) -> bool {
OUTBOUND_SENSITIVE_HEADERS
.iter()
.any(|sensitive| name.eq_ignore_ascii_case(sensitive))
}
fn redact_effect_body(
body: &CapsuleBody,
content_type: &str,
filter: &ParameterFilter,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
location: &str,
) -> CapsuleBody {
let CapsuleBody::Text(text) = body else {
return body.clone();
};
if content_type.contains("json") {
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(text) else {
values.insert(text.as_bytes());
keys.insert(format!("{location}:<unparseable json>"));
return CapsuleBody::Text(FILTERED_PLACEHOLDER.to_owned());
};
let before = keys.len();
let scrubbed = scrub_value(&parsed, filter, location, values, keys);
if keys.len() == before {
return body.clone();
}
retain_raw_json_string_spellings(text.as_bytes(), values);
return serde_json::to_string(&scrubbed)
.map_or_else(|_| CapsuleBody::Absent, CapsuleBody::Text);
}
if content_type.contains("application/x-www-form-urlencoded")
&& let Some(masked) = mask_raw_urlencoded(text, location, filter, values, keys)
{
return CapsuleBody::Text(masked);
}
body.clone()
}
fn mask_body_echoes(body: &mut CapsuleBody, values: &RedactedValues) -> bool {
let CapsuleBody::Text(text) = body else {
return false;
};
let masked = mask_echoes(text, values);
let changed = masked != *text;
*text = masked;
changed
}
fn scrub_encoded_json(
encoded: &str,
filter: &ParameterFilter,
location: &str,
values: &mut RedactedValues,
keys: &mut BTreeSet<String>,
) -> String {
let Ok(bytes) = STANDARD.decode(encoded.as_bytes()) else {
keys.insert(format!("{location}:<undecodable>"));
return STANDARD.encode(FILTERED_PLACEHOLDER.as_bytes());
};
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
values.insert(&bytes);
keys.insert(format!("{location}:<unparseable json>"));
return STANDARD.encode(FILTERED_PLACEHOLDER.as_bytes());
};
let before = keys.len();
let scrubbed = scrub_value(&parsed, filter, location, values, keys);
if keys.len() == before {
return encoded.to_owned();
}
retain_raw_json_string_spellings(&bytes, values);
serde_json::to_vec(&scrubbed).map_or_else(
|_| STANDARD.encode(FILTERED_PLACEHOLDER.as_bytes()),
|bytes| STANDARD.encode(&bytes),
)
}
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", "payment-signature"];
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)
}
pub(crate) fn record_credential_components(name: &str, value: &[u8], values: &mut RedactedValues) {
let trimmed = trim_ows(value);
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)) = split_scheme(trimmed) {
let credential = trim_ows(credential);
if is_auth_scheme(scheme) && !credential.is_empty() {
values.insert(credential);
if scheme.eq_ignore_ascii_case(b"basic") {
record_basic_credentials(credential, values);
}
if !is_token68(credential) {
record_auth_params(credential, values);
}
}
}
if is_cookie && trimmed.contains(&b'=') {
let segments = if name == "set-cookie" { 1 } else { usize::MAX };
let cookie_values = trimmed
.split(|byte| *byte == b';')
.take(segments)
.filter_map(|pair| split_once_byte(pair, b'='))
.filter(|(cookie_name, _)| !trim_ows(cookie_name).is_empty())
.take(MAX_COOKIE_PAIRS);
for (_, cookie_value) in cookie_values {
let spelled = trim_ows(cookie_value);
let unquoted = trim_matches_byte(spelled, b'"');
record_cookie_value(unquoted, values);
if unquoted.len() != spelled.len()
&& unquoted.len() >= MIN_ECHO_LEN
&& !unquoted.iter().all(u8::is_ascii_whitespace)
{
record_cookie_value(spelled, values);
}
}
}
}
fn record_cookie_value(cookie_value: &[u8], values: &mut RedactedValues) {
values.insert_whole_token_only(cookie_value);
let decoded: Cow<'_, [u8]> = percent_encoding::percent_decode(cookie_value).into();
let Cow::Owned(decoded) = decoded else {
return;
};
if decoded.len() < MIN_ECHO_LEN || decoded.iter().all(u8::is_ascii_whitespace) {
return;
}
values.insert_whole_token_only(&decoded);
}
fn record_auth_params(credential: &[u8], values: &mut RedactedValues) {
for param in split_auth_params(credential) {
let Some((name, value)) = split_once_byte(param, b'=') else {
continue;
};
if !is_auth_scheme(trim_ows(name)) {
continue;
}
let value = unquote_auth_param(trim_ows(value));
if !value.is_empty() {
values.insert_whole_token_only(&value);
}
}
}
fn is_token68(credential: &[u8]) -> bool {
let unpadded = trim_end_matches_byte(credential, b'=');
!unpadded.is_empty()
&& unpadded.iter().copied().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
})
}
fn split_auth_params(credential: &[u8]) -> Vec<&[u8]> {
let mut params = Vec::new();
let mut start = 0_usize;
let mut quoted = false;
let mut escaped = false;
for (index, byte) in credential.iter().copied().enumerate() {
if escaped {
escaped = false;
} else if byte == b'\\' && quoted {
escaped = true;
} else if byte == b'"' {
quoted = !quoted;
} else if byte == b',' && !quoted {
if let Some(param) = credential.get(start..index) {
params.push(param);
}
start = index.saturating_add(1);
}
}
if let Some(param) = credential.get(start..) {
params.push(param);
}
params
}
fn unquote_auth_param(value: &[u8]) -> Cow<'_, [u8]> {
let Some(inner) = value.strip_prefix(b"\"") else {
return Cow::Borrowed(value);
};
let mut unquoted = Vec::new();
let mut escaped = false;
for byte in inner.iter().copied() {
if escaped {
unquoted.push(byte);
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
break;
} else {
unquoted.push(byte);
}
}
Cow::Owned(unquoted)
}
fn record_basic_credentials(credential: &[u8], 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: impl AsRef<[u8]>) -> bool {
let word = word.as_ref();
!word.is_empty() && word.iter().copied().all(is_token_byte)
}
const fn is_token_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
fn split_scheme(value: &[u8]) -> Option<(&[u8], &[u8])> {
let end = value
.iter()
.copied()
.position(|byte| !is_token_byte(byte))?;
let scheme = value.get(..end).filter(|scheme| !scheme.is_empty())?;
let rest = value.get(end..)?;
let credential = strip_leading_whitespace(rest);
(credential.len() != rest.len()).then_some((scheme, credential))
}
fn strip_leading_whitespace(mut bytes: &[u8]) -> &[u8] {
loop {
let width = match bytes.first() {
Some(byte) if byte.is_ascii_whitespace() => 1,
Some(_) => match bytes
.get(..bytes.len().min(4))
.and_then(|window| window.utf8_chunks().next())
.and_then(|chunk| chunk.valid().chars().next().filter(|c| c.is_whitespace()))
{
Some(space) => space.len_utf8(),
None => return bytes,
},
None => return bytes,
};
bytes = bytes.get(width..).unwrap_or_default();
}
}
fn split_once_byte(bytes: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> {
let index = bytes.iter().position(|byte| *byte == delimiter)?;
let head = bytes.get(..index)?;
let tail = bytes.get(index.saturating_add(1)..)?;
Some((head, tail))
}
fn trim_matches_byte(bytes: &[u8], byte: u8) -> &[u8] {
let mut trimmed = bytes;
while let Some(rest) = trimmed.strip_prefix(&[byte]) {
trimmed = rest;
}
while let Some(rest) = trimmed.strip_suffix(&[byte]) {
trimmed = rest;
}
trimmed
}
fn trim_end_matches_byte(bytes: &[u8], byte: u8) -> &[u8] {
let mut trimmed = bytes;
while let Some(rest) = trimmed.strip_suffix(&[byte]) {
trimmed = rest;
}
trimmed
}
fn trim_ows(value: &[u8]) -> &[u8] {
let ascii = value.trim_ascii();
std::str::from_utf8(ascii).map_or(ascii, |text| text.trim().as_bytes())
}
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(),
}
}
pub(crate) 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 {
mod effects {
use super::super::*;
use crate::capsule::schema::{
CacheEffect, CapsuleEffects, CapsuleJob, HttpEffect, JobEffect, MailEffect,
TenantEffect,
};
fn filter() -> ParameterFilter {
ParameterFilter::new(&["api_key".to_owned(), "email".to_owned()], &[])
}
fn exchange(url: &str) -> HttpEffect {
HttpEffect {
method: "POST".to_owned(),
url: url.to_owned(),
request_headers: vec![
("content-type".to_owned(), "application/json".to_owned()),
("x-api-key".to_owned(), "sk-live-42".to_owned()),
],
request_body: CapsuleBody::Text(
r#"{"api_key":"sk-live-42","amount":10}"#.to_owned(),
),
status: 502,
response_headers: vec![(
"set-cookie".to_owned(),
"session=abc; HttpOnly".to_owned(),
)],
response_body: CapsuleBody::Text(r#"{"api_key":"sk-live-42"}"#.to_owned()),
error: None,
..Default::default()
}
}
fn redact(effects: &mut CapsuleEffects) -> BTreeSet<String> {
let mut values = RedactedValues::default();
let mut keys = BTreeSet::new();
redact_effects(effects, None, &filter(), &mut values, &mut keys);
keys
}
#[test]
fn an_outbound_credential_header_is_masked_whatever_the_app_filter_says() {
let mut effects = CapsuleEffects {
http: vec![exchange("https://api.example/charge")],
..CapsuleEffects::default()
};
let keys = redact(&mut effects);
let exchange = effects.http.first().expect("one exchange");
assert!(
exchange
.request_headers
.iter()
.any(|(name, value)| name == "x-api-key" && value == "[FILTERED]"),
"{:?}",
exchange.request_headers
);
assert!(
exchange
.response_headers
.iter()
.any(|(name, value)| name == "set-cookie" && value == "[FILTERED]"),
"a Set-Cookie on the response half carries a credential too: {:?}",
exchange.response_headers
);
assert!(
keys.iter()
.any(|key| key.contains("http[0].request_header:x-api-key")),
"{keys:?}"
);
}
#[test]
fn url_userinfo_is_masked_and_echoed_in_the_redirect_target() {
let mut hit = exchange("https://svc:hunter2pw@api.example/start");
hit.final_url = Some("https://api.example/error/hunter2pw".to_owned());
let mut effects = CapsuleEffects {
http: vec![hit],
..CapsuleEffects::default()
};
let keys = redact(&mut effects);
let hit = effects.http.first().expect("one exchange");
assert_eq!(hit.url, "https://[FILTERED]@api.example/start");
let final_url = hit.final_url.as_deref().expect("final url");
assert!(!final_url.contains("hunter2pw"), "{final_url}");
assert!(keys.contains("http[0].url:userinfo"), "{keys:?}");
assert!(keys.contains("http[0].final_url:<echo>"), "{keys:?}");
}
#[test]
fn a_credential_inside_the_placeholder_does_not_corrupt_the_masked_url() {
let mut effects = CapsuleEffects {
http: vec![exchange("https://svc:FILTER@api.example/start")],
..CapsuleEffects::default()
};
redact(&mut effects);
let url = &effects.http.first().expect("one exchange").url;
assert_eq!(url, "https://[FILTERED]@api.example/start");
}
#[test]
fn a_credential_that_begins_with_the_placeholder_is_masked_whole() {
let mut values = RedactedValues::default();
values.insert(b"[FILTERED]hunter2");
assert_eq!(
mask_echoes("rejected [FILTERED]hunter2 and [FILTERED]", &values),
"rejected [FILTERED] and [FILTERED]"
);
}
#[test]
fn url_userinfo_is_found_in_the_authority_only() {
assert_eq!(
hide_url_userinfo("https:\t//a:b@h/p"),
"https://[FILTERED]@h/p"
);
assert_eq!(url_userinfo("https://a:b@h/p"), Some("a:b".to_owned()));
assert_eq!(url_userinfo("https://h/p?x=a@b"), None);
assert_eq!(url_userinfo("https://@h/p"), None);
assert_eq!(url_userinfo("no-scheme"), None);
assert_eq!(url_userinfo("https:a:b@h/p"), Some("a:b".to_owned()));
assert_eq!(url_userinfo("HTTPS:/a:b@h/p"), Some("a:b".to_owned()));
assert_eq!(url_userinfo("https:\\\\a:b@h\\p"), Some("a:b".to_owned()));
assert_eq!(url_userinfo("mailto:a@b"), None);
assert_eq!(url_userinfo("postgres://a:b@h/db"), Some("a:b".to_owned()));
assert_eq!(
hide_url_userinfo("https:a:b@h/p?q=1"),
"https:[FILTERED]@h/p?q=1"
);
}
#[test]
fn url_userinfo_retains_user_password_and_decoded_forms() {
let mut values = RedactedValues::default();
record_userinfo_components("svc%40x:p%20w", &mut values);
for expected in [
&b"svc%40x:p%20w"[..],
b"svc%40x",
b"p%20w",
b"svc@x",
b"p w",
] {
assert!(values.contains(expected), "{expected:?}");
}
}
#[test]
fn a_credential_equal_to_a_host_label_masks_the_host_and_blocks_replay() {
let mut effects = CapsuleEffects {
http: vec![exchange("https://payments:secret@payments.example/x")],
..CapsuleEffects::default()
};
let keys = redact(&mut effects);
let url = &effects.http.first().expect("one exchange").url;
assert_eq!(url, "https://[FILTERED]@[FILTERED].example/x");
assert!(keys.contains("http[0].url:<host-echo>"), "{keys:?}");
}
#[test]
fn userinfo_behind_leading_control_characters_is_still_found() {
assert_eq!(url_userinfo("\n https://a:b@h/p"), Some("a:b".to_owned()));
assert_eq!(hide_url_userinfo("\nhttps:a:b@h/p"), "https:[FILTERED]@h/p");
}
#[test]
fn url_userinfo_retains_the_parser_serialised_spelling() {
let mut values = RedactedValues::default();
record_userinfo_components("svc:p w", &mut values);
assert!(values.contains(b"p w"));
assert!(values.contains(b"p%20w"));
}
#[test]
fn a_url_without_credentials_is_recorded_verbatim_even_with_control_characters() {
let mut effects = CapsuleEffects {
http: vec![exchange("https://host/a\tb?x=1")],
..CapsuleEffects::default()
};
redact(&mut effects);
assert_eq!(
effects.http.first().expect("one exchange").url,
"https://host/a\tb?x=1"
);
}
#[test]
fn url_userinfo_retains_the_decoded_bytes_of_a_non_utf8_credential() {
let mut values = RedactedValues::default();
record_userinfo_components("svc:%FFsecret", &mut values);
assert!(values.contains(b"\xffsecret"));
}
#[test]
fn an_outbound_url_query_is_masked_by_parameter_name() {
let mut effects = CapsuleEffects {
http: vec![exchange(
"https://api.example/charge?api_key=sk-live-42&page=2",
)],
..CapsuleEffects::default()
};
redact(&mut effects);
let url = &effects.http.first().expect("one exchange").url;
assert!(!url.contains("sk-live-42"), "{url}");
assert!(
url.contains("page=2"),
"the rest of the query survives: {url}"
);
}
#[test]
fn outbound_json_bodies_are_masked_by_key_on_both_halves() {
let mut effects = CapsuleEffects {
http: vec![exchange("https://api.example/charge")],
..CapsuleEffects::default()
};
redact(&mut effects);
let exchange = effects.http.first().expect("one exchange");
let serialized = serde_json::to_string(exchange).expect("serializes");
assert!(
!serialized.contains("sk-live-42"),
"no half of the exchange may carry the credential: {serialized}"
);
let CapsuleBody::Text(request_body) = &exchange.request_body else {
panic!("expected a text body, got {:?}", exchange.request_body);
};
assert!(
request_body.contains(r#""amount":10"#),
"unfiltered fields survive: {request_body}"
);
}
#[test]
fn job_payloads_cache_values_and_the_job_entry_are_masked_by_key() {
use base64::Engine as _;
let secret = STANDARD.encode(br#"{"api_key":"sk-live-42"}"#);
let mut effects = CapsuleEffects {
jobs: vec![JobEffect {
name: "notify".to_owned(),
payload: serde_json::json!({"api_key": "sk-live-42", "order": 7}),
delay_secs: None,
due_at: None,
requested_due_at: None,
error: None,
error_status: None,
}],
cache: vec![CacheEffect::Insert {
key: "creds".to_owned(),
value: secret,
ttl_secs: None,
}],
..CapsuleEffects::default()
};
let mut entry = CapsuleJob {
name: "notify".to_owned(),
payload: serde_json::json!({"api_key": "sk-live-42"}),
};
let mut values = RedactedValues::default();
let mut keys = BTreeSet::new();
redact_effects(
&mut effects,
Some(&mut entry),
&filter(),
&mut values,
&mut keys,
);
let serialized = serde_json::to_string(&effects).expect("serializes");
assert!(!serialized.contains("sk-live-42"), "{serialized}");
assert!(
!serde_json::to_string(&entry)
.expect("serializes")
.contains("sk-live-42"),
"a job capsule's own arguments are input, and are masked like any other"
);
let CacheEffect::Insert { value, .. } = effects.cache.first().expect("one entry")
else {
panic!("expected an insert, got {:?}", effects.cache.first());
};
let decoded = STANDARD.decode(value.as_bytes()).expect("base64");
assert!(
!String::from_utf8_lossy(&decoded).contains("sk-live-42"),
"cache values are masked inside their base64 envelope"
);
}
#[test]
fn an_echo_masked_consumed_field_records_a_key() {
let mut values = RedactedValues::default();
values.insert(b"hunter2secret");
let mut effects = CapsuleEffects {
http: vec![HttpEffect {
response_headers: vec![("x-echo".to_owned(), "hunter2secret".to_owned())],
response_body: CapsuleBody::Text(r#"{"note":"hunter2secret"}"#.to_owned()),
..exchange("https://api.example/charge")
}],
cache: vec![
CacheEffect::Get {
key: "k".to_owned(),
value: Some(STANDARD.encode(br#"{"pw":"hunter2secret"}"#)),
},
CacheEffect::Insert {
key: "w".to_owned(),
value: STANDARD.encode(br#"{"pw":"hunter2secret"}"#),
ttl_secs: None,
},
],
..CapsuleEffects::default()
};
let mut entry = CapsuleJob {
name: "n".to_owned(),
payload: serde_json::json!({"pw": "hunter2secret"}),
};
let mut keys = BTreeSet::new();
redact_effects(
&mut effects,
Some(&mut entry),
&filter(),
&mut values,
&mut keys,
);
assert!(
keys.iter().any(|k| k.starts_with("http[0].response_body")),
"{keys:?}"
);
assert!(
keys.iter()
.any(|k| k.starts_with("http[0].response_header:x-echo")),
"{keys:?}"
);
assert!(keys.iter().any(|k| k.starts_with("cache[0]")), "{keys:?}");
assert!(
!keys.iter().any(|k| k.starts_with("cache[1]")),
"a cache write is compared, not consumed: {keys:?}"
);
assert!(keys.iter().any(|k| k.starts_with("job_entry.")), "{keys:?}");
}
#[test]
fn a_literal_placeholder_in_compared_data_is_found() {
let effects = CapsuleEffects {
http: vec![HttpEffect {
request_body: CapsuleBody::Text(r#"{"note":"[FILTERED]"}"#.to_owned()),
..exchange("https://api.example/charge?q=%5BFILTERED%5D")
}],
jobs: vec![JobEffect {
name: "n".to_owned(),
payload: serde_json::json!({"note": "[FILTERED]"}),
..JobEffect::default()
}],
cache: vec![CacheEffect::Get {
key: "k".to_owned(),
value: Some(STANDARD.encode(br#""[FILTERED]""#)),
}],
..CapsuleEffects::default()
};
let found = literal_placeholder_locations(&effects);
assert!(found.contains(&"http[0].url".to_owned()), "{found:?}");
assert!(
found.contains(&"http[0].request_body".to_owned()),
"{found:?}"
);
assert!(found.contains(&"job[0].payload".to_owned()), "{found:?}");
assert!(
!found.iter().any(|f| f.starts_with("cache[0]")),
"a read value is served, never a wildcard: {found:?}"
);
assert!(literal_placeholder_locations(&CapsuleEffects::default()).is_empty());
}
#[test]
fn placeholder_bytes_in_a_binary_body_are_not_reported() {
let mut bytes = vec![0xff, 0xfe];
bytes.extend_from_slice(b"[FILTERED][FILTERED]");
let effects = CapsuleEffects {
http: vec![HttpEffect {
request_body: CapsuleBody::Base64(STANDARD.encode(&bytes)),
..exchange("https://api.example/upload")
}],
..CapsuleEffects::default()
};
assert!(literal_placeholder_locations(&effects).is_empty());
assert!(adjacent_placeholder_locations(&effects).is_empty());
}
#[test]
fn a_placeholder_in_a_job_payload_key_is_not_a_literal() {
let effects = CapsuleEffects {
jobs: vec![JobEffect {
name: "n".to_owned(),
payload: serde_json::json!({"[FILTERED]": {"list": ["plain"]}}),
..JobEffect::default()
}],
..CapsuleEffects::default()
};
assert!(literal_placeholder_locations(&effects).is_empty());
let nested = CapsuleEffects {
jobs: vec![JobEffect {
name: "n".to_owned(),
payload: serde_json::json!({"a": [{"b": "x [FILTERED]"}]}),
..JobEffect::default()
}],
..CapsuleEffects::default()
};
assert_eq!(literal_placeholder_locations(&nested), ["job[0].payload"]);
}
#[test]
fn a_value_masked_out_of_the_request_is_masked_wherever_an_effect_echoes_it() {
let mut values = RedactedValues::default();
values.insert(b"hunter2secret");
let mut effects = CapsuleEffects {
jobs: vec![JobEffect {
name: "notify".to_owned(),
payload: serde_json::json!({"pw": "hunter2secret"}),
delay_secs: None,
due_at: None,
requested_due_at: None,
error: None,
error_status: None,
}],
mail: vec![MailEffect {
to: vec!["hunter2secret@example.com".to_owned()],
from: None,
subject: "hunter2secret".to_owned(),
body: CapsuleBody::Text("your key is hunter2secret".to_owned()),
error: None,
..Default::default()
}],
tenant: Some(TenantEffect {
id: Some("hunter2secret".to_owned()),
}),
..CapsuleEffects::default()
};
let mut keys = BTreeSet::new();
redact_effects(&mut effects, None, &filter(), &mut values, &mut keys);
let serialized = serde_json::to_string(&effects).expect("serializes");
assert!(
!serialized.contains("hunter2secret"),
"a value redaction already removed must not reappear on another seam: \
{serialized}"
);
}
#[test]
fn an_unparseable_json_effect_body_is_replaced_rather_than_copied() {
let mut effects = CapsuleEffects {
http: vec![HttpEffect {
request_body: CapsuleBody::Text(r#"{"api_key":"sk-live-42","#.to_owned()),
..exchange("https://api.example/charge")
}],
..CapsuleEffects::default()
};
redact(&mut effects);
let body = &effects.http.first().expect("one exchange").request_body;
assert_eq!(
body,
&CapsuleBody::Text("[FILTERED]".to_owned()),
"a body whose keys cannot be read cannot be shown to be safe"
);
}
}
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 an_x402_payment_is_masked_unconditionally() {
let (request, values) = redact(
Request::get("/x").header("payment-signature", "eyJ4NDAyVmVyc2lvbiI6Mn0"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert_eq!(
header_value(&request, "payment-signature"),
FILTERED_PLACEHOLDER,
"an unspent payment must never be stored in a capsule"
);
assert!(values.contains(b"eyJ4NDAyVmVyc2lvbiI6Mn0"));
}
#[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_non_utf8_header_byte_does_not_hide_the_ascii_components() {
let mut credential = br#"username=""#.to_vec();
credential.push(0xFF);
credential.extend_from_slice(br#"alice", response="deadbeef", nonce=cafebabe, qop=auth"#);
let mut header = b"Digest ".to_vec();
header.extend_from_slice(&credential);
let (_request, values) = redact(
Request::get("/").header(
header::AUTHORIZATION,
axum::http::HeaderValue::from_bytes(&header).expect("obs-text is legal"),
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(&credential),
"the credential after the scheme is still retained whole, as bytes"
);
assert!(
values.contains(b"deadbeef"),
"an ASCII param value must survive a non-UTF-8 byte elsewhere in the header"
);
assert!(
values.contains(b"cafebabe"),
"an unquoted param value after the non-UTF-8 one is still found"
);
assert!(
values.contains(b"auth"),
"every remaining param value is recorded, as before"
);
assert!(
values.contains(b"\xffalice".as_slice()),
"the non-UTF-8 param value itself is retained as bytes, for bind masking"
);
assert!(
!values.contains(b"username"),
"param names are not values and must stay out of the echo set"
);
let mut binds = vec![
BindValue::Value(b"deadbeef".to_vec()),
BindValue::Value(b"\xffalice".to_vec()),
BindValue::Value(b"unrelated".to_vec()),
];
mask_binds(&mut binds, &values);
assert_eq!(
binds.first(),
Some(&BindValue::Masked),
"a bind byte-equal to an ASCII param value is masked"
);
assert_eq!(
binds.get(1),
Some(&BindValue::Masked),
"a bind byte-equal to the non-UTF-8 param value is masked too"
);
assert_eq!(
binds.get(2),
Some(&BindValue::Value(b"unrelated".to_vec())),
"unrelated binds are untouched"
);
assert_eq!(
mask_echoes("response deadbeef rejected", &values),
format!("response {FILTERED_PLACEHOLDER} rejected"),
"the extracted param value is scrubbed from the outcome"
);
let mut cookie = b"pref=".to_vec();
cookie.push(0xFF);
cookie.extend_from_slice(b"dark; session=sess-abcdef");
let (_request, jar) = redact(
Request::get("/").header(
header::COOKIE,
axum::http::HeaderValue::from_bytes(&cookie).expect("obs-text is legal"),
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
jar.contains(b"sess-abcdef"),
"an ASCII cookie value must survive a non-UTF-8 cookie beside it"
);
assert!(
jar.contains(b"\xffdark".as_slice()),
"the non-UTF-8 cookie value is retained as bytes"
);
}
#[test]
fn a_non_utf8_authorization_is_read_only_where_the_syntax_survives() {
let authorization = |bytes: &[u8]| {
let (_request, values) = redact(
Request::get("/").header(
header::AUTHORIZATION,
axum::http::HeaderValue::from_bytes(bytes).expect("obs-text is legal"),
),
CapturedBody::Absent,
&filter_with(&[]),
);
values
};
let mut not_base64 = b"Basic ".to_vec();
not_base64.push(0xFF);
not_base64.extend_from_slice(b"abc");
let values = authorization(¬_base64);
assert!(
values.contains(b"\xffabc".as_slice()),
"the credential after a known scheme is retained even when it does not decode"
);
let mut no_space = b"hunter2".to_vec();
no_space.push(0xFF);
let values = authorization(&no_space);
assert!(
values.contains(no_space.as_slice()),
"the whole value is retained, as for any masked header"
);
assert!(
!values.contains(b"hunter2"),
"a prefix of an unsplittable value is not a component"
);
let mut bad_scheme = b"Bea".to_vec();
bad_scheme.push(0xFF);
bad_scheme.extend_from_slice(b"rer hunter2secret");
let values = authorization(&bad_scheme);
assert!(
!values.contains(b"hunter2secret"),
"guessing a credential after an invalid scheme would invent secrets"
);
}
#[test]
fn unicode_padding_around_a_header_value_does_not_hide_the_credential() {
for padded in [
"\u{a0}Bearer hunter2secret",
"Bearer hunter2secret\u{a0}",
"\u{2028}Bearer hunter2secret\u{2028}",
"Bearer \u{a0}hunter2secret",
"Bearer hunter2secret\u{a0}",
"Bearer \u{a0}hunter2secret\u{a0}",
] {
let (_request, values) = redact(
Request::get("/").header(header::AUTHORIZATION, padded),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"hunter2secret"),
"the credential must survive Unicode padding ({padded:?})"
);
let mut binds = vec![BindValue::Value(b"hunter2secret".to_vec())];
mask_binds(&mut binds, &values);
assert_eq!(
binds.first(),
Some(&BindValue::Masked),
"a bind carrying what the handler trimmed to must be masked ({padded:?})"
);
}
let (_request, params) = redact(
Request::get("/").header(
header::AUTHORIZATION,
"Digest \u{a0}response=\u{a0}deadbeef\u{a0}, nonce=\u{a0}cafebabe",
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
params.contains(b"deadbeef"),
"a padded param name must not take its value down with it"
);
assert!(
params.contains(b"cafebabe"),
"a padded param value is recorded as the handler holds it"
);
let (_request, cookie) = redact(
Request::get("/").header(header::COOKIE, "session=\u{a0}sess-abcdef\u{a0}"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
cookie.contains(b"sess-abcdef"),
"a padded cookie value is recorded as the handler holds it"
);
}
#[test]
fn any_whitespace_after_the_scheme_still_yields_the_credential() {
for separated in [
"Bearer\thunter2secret",
"Bearer \t hunter2secret",
"Bearer\u{a0}hunter2secret",
] {
let (_request, values) = redact(
Request::get("/").header(header::AUTHORIZATION, separated),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"hunter2secret"),
"the credential must survive this separator ({separated:?})"
);
}
let (_request, params) = redact(
Request::get("/").header(
header::AUTHORIZATION,
"Digest\tresponse=deadbeef,\tnonce=cafebabe",
),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(params.contains(b"deadbeef"), "first param value");
assert!(params.contains(b"cafebabe"), "param after an HTAB");
for odd in [
&b"Bearer\xc3\xa9tok1234"[..],
b"Bearer\t",
b"Bearer\xfftok1234",
] {
let mut odd_values = RedactedValues::default();
record_credential_components("authorization", odd, &mut odd_values);
assert!(!odd_values.contains(b"tok1234"), "{odd:?}");
}
let (_request, wide) = redact(
Request::get("/").header(header::AUTHORIZATION, "Bearer\u{3000}\u{a0}tok1234"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(wide.contains(b"tok1234"));
let (_request, not_scheme) = redact(
Request::get("/").header(header::AUTHORIZATION, "a=b\tvalue1234"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
!not_scheme.contains(b"value1234"),
"a head holding `=` is not a scheme"
);
}
#[test]
fn a_quoted_cookie_value_records_both_spellings() {
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, "session=\"abcdef\"; theme=\"x\"; e=\"\""),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(values.contains(b"abcdef"), "the unquoted spelling");
assert!(values.contains(b"\"abcdef\""), "the wire spelling");
assert!(
!values.contains(b"\"x\""),
"a quoted spelling under the floor is not recorded"
);
assert!(
!values.contains(b"\"\""),
"an empty quoted value is nothing"
);
let (_request, set_cookie) = redact(
Request::get("/").header("set-cookie", "s=\"abcdef\"; Path=\"/admin\""),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(set_cookie.contains(b"\"abcdef\""));
assert!(!set_cookie.contains(b"\"/admin\""));
let mut binds = vec![BindValue::Value(b"\"abcdef\"".to_vec())];
mask_binds(&mut binds, &values);
assert_eq!(binds.first(), Some(&BindValue::Masked));
let (_request, encoded) = redact(
Request::get("/").header(header::COOKIE, "session=\"abc%2Fdef\""),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(encoded.contains(b"\"abc%2Fdef\""));
assert!(encoded.contains(b"abc/def"));
assert!(encoded.contains(b"\"abc/def\""));
}
#[test]
fn cookie_pairs_beyond_the_cap_are_not_recorded() {
let header_value = (0..MAX_COOKIE_PAIRS + 10)
.map(|i| format!("k{i}=value{i:05}"))
.collect::<Vec<_>>()
.join("; ");
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, header_value),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(values.contains(b"value00000"), "first pair");
assert!(
values.contains(format!("value{:05}", MAX_COOKIE_PAIRS - 1).as_bytes()),
"last pair under the cap"
);
assert!(
!values.contains(format!("value{MAX_COOKIE_PAIRS:05}").as_bytes()),
"first pair over the cap"
);
}
#[test]
fn junk_segments_do_not_use_up_the_cookie_cap() {
let junk = "; ".repeat(MAX_COOKIE_PAIRS + 10);
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, format!("a{junk}session=secretvalue")),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(values.contains(b"secretvalue"), "real cookie after junk");
}
#[test]
fn nameless_segments_do_not_use_up_the_cookie_cap() {
let junk = "=junk; ".repeat(MAX_COOKIE_PAIRS + 10);
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, format!("{junk}session=secretvalue")),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(values.contains(b"secretvalue"), "real cookie after junk");
}
#[test]
fn a_long_unicode_separator_run_is_trimmed_in_linear_time() {
let mut header = b"Bearer ".to_vec();
header.extend("\u{a0}".repeat(100_000).as_bytes());
header.extend_from_slice(b"hunter2secret");
let mut values = RedactedValues::default();
let started = std::time::Instant::now();
record_credential_components("authorization", &header, &mut values);
assert!(values.contains(b"hunter2secret"));
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"trimming took {:?}",
started.elapsed()
);
}
#[test]
fn a_set_cookie_attribute_is_never_read_as_the_cookie() {
let (_request, values) = redact(
Request::get("/").header("set-cookie", "junk; Path=/admin"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(!values.contains(b"/admin"));
}
#[test]
fn a_unicode_separator_survives_a_non_utf8_credential() {
let mut values = RedactedValues::default();
record_credential_components(
"authorization",
b"Digest\xc2\xa0username=\xff, response=deadbeef",
&mut values,
);
assert!(values.contains(b"deadbeef"));
assert!(values.contains(b"username=\xff, response=deadbeef"));
}
#[test]
fn a_quoted_cookie_value_under_the_floor_keeps_only_its_bare_form() {
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, "a=\"no\""),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(values.contains(b"no"));
assert!(!values.contains(b"\"no\""));
}
#[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 encoded_cookie_value_forms_join_the_echo_set() {
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, "session=abc%2Fdef; theme=dark"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"abc%2Fdef"),
"the on-the-wire spelling is still recorded"
);
assert!(
values.contains(b"abc/def"),
"the percent-decoded spelling is what a decoding handler holds"
);
assert!(
values.contains(b"dark"),
"a value with nothing to decode is still recorded, in the one spelling it has"
);
let mut binds = vec![
BindValue::Value(b"abc/def".to_vec()),
BindValue::Value(b"abc%2Fdef".to_vec()),
];
mask_binds(&mut binds, &values);
assert_eq!(
binds.first(),
Some(&BindValue::Masked),
"a bind carrying the decoded spelling is masked"
);
assert_eq!(
binds.get(1),
Some(&BindValue::Masked),
"a bind carrying the raw spelling is masked"
);
assert_eq!(
mask_echoes("cookie abc/def was rejected", &values),
format!("cookie {FILTERED_PLACEHOLDER} was rejected"),
"the decoded spelling is scrubbed where it stands as a whole token"
);
assert_eq!(
mask_echoes("darkness check failed", &values),
"darkness check failed",
"the decoded spellings inherit the whole-token classification"
);
let (_request, literal) = redact(
Request::get("/").header(header::COOKIE, "session=abc/def"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(literal.contains(b"abc/def"), "the literal spelling counts");
assert_eq!(
mask_echoes("cookie abc/def was rejected", &literal),
format!("cookie {FILTERED_PLACEHOLDER} was rejected"),
);
let (_request, set_cookie) = redact(
Request::get("/").header("set-cookie", "session=a%2Fbcd; Path=%2Fadmin; Max-Age=0"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(set_cookie.contains(b"a/bcd"), "the cookie value decodes");
assert!(
!set_cookie.contains(b"/admin"),
"an attribute value is not a secret, decoded or not"
);
let (_request, binary) = redact(
Request::get("/").header(header::COOKIE, "session=raw%FFtail"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
binary.contains(b"raw\xfftail".as_slice()),
"a decoded value that is not UTF-8 is still recorded, for bind masking"
);
let (_request, plus) = redact(
Request::get("/").header(header::COOKIE, "session=a+bcd"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(plus.contains(b"a+bcd"), "the wire spelling counts");
assert!(
!plus.contains(b"a bcd"),
"`+` must not be decoded as a space for a cookie"
);
}
#[test]
fn a_short_decoded_cookie_spelling_is_not_worth_what_it_would_shred() {
let (_request, values) = redact(
Request::get("/").header(
header::COOKIE,
"nav=%2F; eq=%3D; zero=%30; pad=%20; blank=%20%20%20%20",
),
CapturedBody::Absent,
&filter_with(&[]),
);
for spelling in [b"%2F".as_slice(), b"%3D", b"%30", b"%20"] {
assert!(
values.contains(spelling),
"the wire spelling is still recorded, however short: {}",
String::from_utf8_lossy(spelling)
);
}
for shredder in [b"/".as_slice(), b"=", b"0", b" ", b" "] {
assert!(
!values.contains(shredder),
"a decoded spelling below the echo floor must stay out: {:?}",
String::from_utf8_lossy(shredder)
);
}
assert_eq!(
mask_echoes("failed at / with status 0", &values),
"failed at / with status 0",
"a one-byte decoded needle would have rewritten ordinary outcome text"
);
assert_eq!(
mask_echoes("x = y", &values),
"x = y",
"as would a decoded `=`"
);
let mut binds = vec![
BindValue::Value(b"/".to_vec()),
BindValue::Value(b"0".to_vec()),
];
mask_binds(&mut binds, &values);
assert_eq!(
binds,
vec![
BindValue::Value(b"/".to_vec()),
BindValue::Value(b"0".to_vec()),
],
"blanking these would drop both columns from replay's bind comparison"
);
}
#[test]
fn malformed_percent_escapes_contribute_no_invented_spelling() {
let (_request, values) = redact(
Request::get("/").header(
header::COOKIE,
"a=bad%ZZtail; b=short%A; c=trailing%; d=full100%",
),
CapturedBody::Absent,
&filter_with(&[]),
);
for raw in [
b"bad%ZZtail".as_slice(),
b"short%A",
b"trailing%",
b"full100%",
] {
assert!(
values.contains(raw),
"the raw spelling is recorded: {}",
String::from_utf8_lossy(raw)
);
}
assert!(
!values.contains(b"badtail"),
"a malformed escape must not be silently dropped into a new spelling"
);
assert_eq!(
mask_echoes("value bad%ZZtail rejected", &values),
format!("value {FILTERED_PLACEHOLDER} rejected"),
"the spelling that did arrive is still scrubbed"
);
}
#[test]
fn an_empty_cookie_value_records_nothing_and_stops_nothing() {
let (_request, values) = redact(
Request::get("/").header(header::COOKIE, "session=; theme=dark; token=sess-abcdef"),
CapturedBody::Absent,
&filter_with(&[]),
);
assert!(
values.contains(b"sess-abcdef") && values.contains(b"dark"),
"an empty pair must not stop the pairs after it"
);
assert!(
!values.contains(b""),
"an empty value is not a secret and must never become a needle"
);
}
#[test]
fn a_direct_insert_outranks_a_decoded_cookie_spelling() {
let (_request, values) = redact(
Request::post("/login")
.header(header::COOKIE, "session=abc%2Fdef")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
CapturedBody::Buffered(Bytes::from_static(b"token=abc%2Fdef")),
&filter_with(&["token"]),
);
assert_eq!(
mask_echoes("upstream rejected abc/defsuffix", &values),
format!("upstream rejected {FILTERED_PLACEHOLDER}suffix"),
"the form field carried these bytes in their own right, so they mask mid-token"
);
}
#[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"
);
}
}