use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum QueryLogMode {
#[default]
Redact,
Drop,
Full,
}
pub const SENSITIVE_KEYS: &[&str] = &[
"key",
"token",
"secret",
"password",
"passwd",
"pwd",
"auth",
"credential",
"session",
"sig",
"signature",
"code",
"state",
"nonce",
"assertion",
"email",
"phone",
"ssn",
];
const REDACTED: &str = "<redacted>";
fn decode_component(s: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
if !s.contains('%') && !s.contains('+') {
return Cow::Borrowed(s);
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
match u8::from_str_radix(&s[i + 1..i + 3], 16) {
Ok(b) => {
out.push(b);
i += 3;
}
Err(_) => {
out.push(b'%');
i += 1;
}
}
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
Cow::Owned(String::from_utf8_lossy(&out).into_owned())
}
fn key_is_sensitive(key: &str, extra: &[String]) -> bool {
raw_key_is_sensitive(key, extra) || raw_key_is_sensitive(&decode_component(key), extra)
}
fn raw_key_is_sensitive(key: &str, extra: &[String]) -> bool {
let lower = key.to_ascii_lowercase();
SENSITIVE_KEYS.iter().any(|k| lower.contains(k))
|| extra
.iter()
.any(|k| !k.is_empty() && lower.contains(&k.to_ascii_lowercase()))
}
fn value_looks_like_a_credential(value: &str) -> bool {
raw_value_looks_like_a_credential(value)
|| raw_value_looks_like_a_credential(&decode_component(value))
}
fn raw_value_looks_like_a_credential(value: &str) -> bool {
if looks_like_jwt(value) {
return true;
}
value.len() >= 24
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
&& value.bytes().any(|b| b.is_ascii_digit())
}
fn looks_like_jwt(value: &str) -> bool {
let mut parts = value.split('.');
let (Some(h), Some(p), Some(s), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
h.starts_with("eyJ")
&& h.len() >= 8
&& !p.is_empty()
&& !s.is_empty()
&& [h, p, s].iter().all(|seg| {
seg.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
})
}
pub fn sanitize_target<'a>(
target: &'a str,
mode: QueryLogMode,
extra_keys: &[String],
) -> std::borrow::Cow<'a, str> {
use std::borrow::Cow;
if mode == QueryLogMode::Full {
return Cow::Borrowed(target);
}
let Some((path, query)) = target.split_once('?') else {
return Cow::Borrowed(target);
};
if mode == QueryLogMode::Drop {
return Cow::Owned(format!("{path}?<dropped>"));
}
let mut out = String::with_capacity(target.len());
out.push_str(path);
out.push('?');
for (i, pair) in query.split('&').enumerate() {
if i > 0 {
out.push('&');
}
match pair.split_once('=') {
None => out.push_str(pair),
Some((k, v)) => {
out.push_str(k);
out.push('=');
if v.is_empty() {
continue;
}
if key_is_sensitive(k, extra_keys) || value_looks_like_a_credential(v) {
out.push_str(REDACTED);
} else {
out.push_str(v);
}
}
}
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn red(target: &str) -> String {
sanitize_target(target, QueryLogMode::Redact, &[]).into_owned()
}
#[test]
fn a_target_without_a_query_is_untouched_and_unallocated() {
let out = sanitize_target("/a/b/c", QueryLogMode::Redact, &[]);
assert_eq!(out, "/a/b/c");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn sensitive_names_are_redacted_and_ordinary_ones_kept() {
assert_eq!(red("/x?page=2&sort=name"), "/x?page=2&sort=name");
assert_eq!(red("/x?api_key=abc123"), "/x?api_key=<redacted>");
assert_eq!(red("/x?token=abc"), "/x?token=<redacted>");
assert_eq!(red("/x?email=a@b.com"), "/x?email=<redacted>");
assert_eq!(
red("/x?code=xyz&state=q"),
"/x?code=<redacted>&state=<redacted>"
);
assert_eq!(
red("/search?q=rust&session=deadbeef&page=3"),
"/search?q=rust&session=<redacted>&page=3"
);
}
#[test]
fn name_matching_is_case_insensitive_and_substring() {
assert_eq!(red("/x?X-Api-Key=v"), "/x?X-Api-Key=<redacted>");
assert_eq!(red("/x?refreshToken=v"), "/x?refreshToken=<redacted>");
assert_eq!(red("/x?ACCESS_TOKEN=v"), "/x?ACCESS_TOKEN=<redacted>");
}
#[test]
fn a_jwt_is_redacted_whatever_the_parameter_is_called() {
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc-_123";
assert_eq!(red(&format!("/x?t={jwt}")), "/x?t=<redacted>");
}
#[test]
fn a_long_opaque_value_is_redacted_but_ordinary_values_are_not() {
assert_eq!(red("/x?ref=aB3dE5fG7hJ9kL1mN3pQ5rS7"), "/x?ref=<redacted>");
assert_eq!(
red("/x?q=how-do-i-configure-the-proxy"),
"/x?q=how-do-i-configure-the-proxy"
);
assert_eq!(
red("/x?from=2026-01-01&to=2026-02-01"),
"/x?from=2026-01-01&to=2026-02-01"
);
assert_eq!(red("/x?id=12345"), "/x?id=12345");
}
#[test]
fn redaction_reveals_neither_the_value_nor_its_length() {
let short = red("/x?token=a");
let long = red(&format!("/x?token={}", "a".repeat(500)));
assert_eq!(short, long);
}
#[test]
fn empty_values_and_bare_flags_are_preserved() {
assert_eq!(red("/x?debug"), "/x?debug");
assert_eq!(red("/x?token="), "/x?token=");
assert_eq!(red("/x?a=1&debug&b=2"), "/x?a=1&debug&b=2");
}
#[test]
fn operator_supplied_names_are_honoured() {
let extra = vec!["accountnumber".to_string()];
assert_eq!(
sanitize_target("/x?accountNumber=99", QueryLogMode::Redact, &extra),
"/x?accountNumber=<redacted>"
);
let empty = vec![String::new()];
assert_eq!(
sanitize_target("/x?page=2", QueryLogMode::Redact, &empty),
"/x?page=2"
);
}
#[test]
fn drop_keeps_the_path_and_the_fact_that_parameters_existed() {
assert_eq!(
sanitize_target("/x?token=a&b=2", QueryLogMode::Drop, &[]),
"/x?<dropped>"
);
assert_eq!(sanitize_target("/x", QueryLogMode::Drop, &[]), "/x");
}
#[test]
fn full_is_verbatim() {
assert_eq!(
sanitize_target("/x?token=supersecret", QueryLogMode::Full, &[]),
"/x?token=supersecret"
);
}
#[test]
fn the_shipped_reference_config_parses_the_log_section() {
let cfg: crate::config::Config = toml::from_str(include_str!("../edgeguard.toml"))
.expect("edgeguard.toml must deserialize into Config");
assert_eq!(cfg.log.query, QueryLogMode::Redact);
assert!(cfg.log.redact_params.is_empty());
}
#[test]
fn the_default_mode_redacts() {
assert_eq!(QueryLogMode::default(), QueryLogMode::Redact);
}
}
#[cfg(test)]
mod encoding_tests {
use super::*;
fn red(target: &str) -> String {
sanitize_target(target, QueryLogMode::Redact, &[]).into_owned()
}
#[test]
fn a_percent_encoded_name_does_not_slip_past_the_name_list() {
assert_eq!(
red("/x?%74%6f%6b%65%6e=secret"),
"/x?%74%6f%6b%65%6e=<redacted>"
);
assert_eq!(red("/x?api%5Fkey=secret"), "/x?api%5Fkey=<redacted>");
assert_eq!(
red("/x?%50%61%73%73%77%6F%72%64=hunter2"),
"/x?%50%61%73%73%77%6F%72%64=<redacted>"
);
}
#[test]
fn a_percent_encoded_jwt_is_still_caught_by_shape() {
let jwt = "eyJhbGciOiJIUzI1NiJ9%2EeyJzdWIiOiIxIn0%2Eabc-_123";
assert_eq!(red(&format!("/x?t={jwt}")), "/x?t=<redacted>");
}
#[test]
fn plus_is_treated_as_a_space_when_classifying() {
assert_eq!(red("/x?access+token=v"), "/x?access+token=<redacted>");
}
#[test]
fn decoding_does_not_make_ordinary_values_look_sensitive() {
assert_eq!(
red("/x?q=hello%20world&page=2"),
"/x?q=hello%20world&page=2"
);
assert_eq!(red("/x?name=Jos%C3%A9"), "/x?name=Jos%C3%A9");
}
#[test]
fn a_stray_percent_is_not_an_escape_and_does_not_panic() {
for t in ["/x?q=100%", "/x?q=%zz", "/x?%=1", "/x?q=%2", "/x?%GG%=v"] {
let _ = red(t);
}
assert_eq!(decode_component("100%"), "100%");
assert_eq!(decode_component("%zz"), "%zz");
}
#[test]
fn the_output_keeps_the_original_encoding() {
assert_eq!(red("/x?q=a%20b"), "/x?q=a%20b");
}
}