1use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum QueryLogMode {
29 #[default]
32 Redact,
33 Drop,
36 Full,
39}
40
41pub const SENSITIVE_KEYS: &[&str] = &[
47 "key",
48 "token",
49 "secret",
50 "password",
51 "passwd",
52 "pwd",
53 "auth",
54 "credential",
55 "session",
56 "sig",
57 "signature",
58 "code",
59 "state",
60 "nonce",
61 "assertion",
62 "email",
63 "phone",
64 "ssn",
65];
66
67const REDACTED: &str = "<redacted>";
70
71fn decode_component(s: &str) -> std::borrow::Cow<'_, str> {
83 use std::borrow::Cow;
84 if !s.contains('%') && !s.contains('+') {
85 return Cow::Borrowed(s);
86 }
87 let bytes = s.as_bytes();
88 let mut out = Vec::with_capacity(bytes.len());
89 let mut i = 0;
90 while i < bytes.len() {
91 match bytes[i] {
92 b'%' if i + 2 < bytes.len() => {
93 match u8::from_str_radix(&s[i + 1..i + 3], 16) {
94 Ok(b) => {
95 out.push(b);
96 i += 3;
97 }
98 Err(_) => {
100 out.push(b'%');
101 i += 1;
102 }
103 }
104 }
105 b'+' => {
106 out.push(b' ');
107 i += 1;
108 }
109 b => {
110 out.push(b);
111 i += 1;
112 }
113 }
114 }
115 Cow::Owned(String::from_utf8_lossy(&out).into_owned())
116}
117
118fn key_is_sensitive(key: &str, extra: &[String]) -> bool {
124 raw_key_is_sensitive(key, extra) || raw_key_is_sensitive(&decode_component(key), extra)
125}
126
127fn raw_key_is_sensitive(key: &str, extra: &[String]) -> bool {
128 let lower = key.to_ascii_lowercase();
129 SENSITIVE_KEYS.iter().any(|k| lower.contains(k))
130 || extra
131 .iter()
132 .any(|k| !k.is_empty() && lower.contains(&k.to_ascii_lowercase()))
133}
134
135fn value_looks_like_a_credential(value: &str) -> bool {
143 raw_value_looks_like_a_credential(value)
144 || raw_value_looks_like_a_credential(&decode_component(value))
145}
146
147fn raw_value_looks_like_a_credential(value: &str) -> bool {
148 if looks_like_jwt(value) {
149 return true;
150 }
151 value.len() >= 24
152 && value
153 .bytes()
154 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
155 && value.bytes().any(|b| b.is_ascii_digit())
157}
158
159fn looks_like_jwt(value: &str) -> bool {
160 let mut parts = value.split('.');
161 let (Some(h), Some(p), Some(s), None) =
162 (parts.next(), parts.next(), parts.next(), parts.next())
163 else {
164 return false;
165 };
166 h.starts_with("eyJ")
168 && h.len() >= 8
169 && !p.is_empty()
170 && !s.is_empty()
171 && [h, p, s].iter().all(|seg| {
172 seg.bytes()
173 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
174 })
175}
176
177pub fn sanitize_target<'a>(
182 target: &'a str,
183 mode: QueryLogMode,
184 extra_keys: &[String],
185) -> std::borrow::Cow<'a, str> {
186 use std::borrow::Cow;
187 if mode == QueryLogMode::Full {
188 return Cow::Borrowed(target);
189 }
190 let Some((path, query)) = target.split_once('?') else {
191 return Cow::Borrowed(target);
192 };
193 if mode == QueryLogMode::Drop {
194 return Cow::Owned(format!("{path}?<dropped>"));
195 }
196 let mut out = String::with_capacity(target.len());
197 out.push_str(path);
198 out.push('?');
199 for (i, pair) in query.split('&').enumerate() {
200 if i > 0 {
201 out.push('&');
202 }
203 match pair.split_once('=') {
204 None => out.push_str(pair),
206 Some((k, v)) => {
207 out.push_str(k);
208 out.push('=');
209 if v.is_empty() {
210 continue;
211 }
212 if key_is_sensitive(k, extra_keys) || value_looks_like_a_credential(v) {
213 out.push_str(REDACTED);
214 } else {
215 out.push_str(v);
216 }
217 }
218 }
219 }
220 Cow::Owned(out)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn red(target: &str) -> String {
228 sanitize_target(target, QueryLogMode::Redact, &[]).into_owned()
229 }
230
231 #[test]
232 fn a_target_without_a_query_is_untouched_and_unallocated() {
233 let out = sanitize_target("/a/b/c", QueryLogMode::Redact, &[]);
234 assert_eq!(out, "/a/b/c");
235 assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
236 }
237
238 #[test]
239 fn sensitive_names_are_redacted_and_ordinary_ones_kept() {
240 assert_eq!(red("/x?page=2&sort=name"), "/x?page=2&sort=name");
241 assert_eq!(red("/x?api_key=abc123"), "/x?api_key=<redacted>");
242 assert_eq!(red("/x?token=abc"), "/x?token=<redacted>");
243 assert_eq!(red("/x?email=a@b.com"), "/x?email=<redacted>");
244 assert_eq!(
245 red("/x?code=xyz&state=q"),
246 "/x?code=<redacted>&state=<redacted>"
247 );
248 assert_eq!(
250 red("/search?q=rust&session=deadbeef&page=3"),
251 "/search?q=rust&session=<redacted>&page=3"
252 );
253 }
254
255 #[test]
256 fn name_matching_is_case_insensitive_and_substring() {
257 assert_eq!(red("/x?X-Api-Key=v"), "/x?X-Api-Key=<redacted>");
258 assert_eq!(red("/x?refreshToken=v"), "/x?refreshToken=<redacted>");
259 assert_eq!(red("/x?ACCESS_TOKEN=v"), "/x?ACCESS_TOKEN=<redacted>");
260 }
261
262 #[test]
263 fn a_jwt_is_redacted_whatever_the_parameter_is_called() {
264 let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc-_123";
266 assert_eq!(red(&format!("/x?t={jwt}")), "/x?t=<redacted>");
267 }
268
269 #[test]
270 fn a_long_opaque_value_is_redacted_but_ordinary_values_are_not() {
271 assert_eq!(red("/x?ref=aB3dE5fG7hJ9kL1mN3pQ5rS7"), "/x?ref=<redacted>");
272 assert_eq!(
274 red("/x?q=how-do-i-configure-the-proxy"),
275 "/x?q=how-do-i-configure-the-proxy"
276 );
277 assert_eq!(
278 red("/x?from=2026-01-01&to=2026-02-01"),
279 "/x?from=2026-01-01&to=2026-02-01"
280 );
281 assert_eq!(red("/x?id=12345"), "/x?id=12345");
282 }
283
284 #[test]
285 fn redaction_reveals_neither_the_value_nor_its_length() {
286 let short = red("/x?token=a");
287 let long = red(&format!("/x?token={}", "a".repeat(500)));
288 assert_eq!(short, long);
289 }
290
291 #[test]
292 fn empty_values_and_bare_flags_are_preserved() {
293 assert_eq!(red("/x?debug"), "/x?debug");
294 assert_eq!(red("/x?token="), "/x?token=");
295 assert_eq!(red("/x?a=1&debug&b=2"), "/x?a=1&debug&b=2");
296 }
297
298 #[test]
299 fn operator_supplied_names_are_honoured() {
300 let extra = vec!["accountnumber".to_string()];
301 assert_eq!(
302 sanitize_target("/x?accountNumber=99", QueryLogMode::Redact, &extra),
303 "/x?accountNumber=<redacted>"
304 );
305 let empty = vec![String::new()];
307 assert_eq!(
308 sanitize_target("/x?page=2", QueryLogMode::Redact, &empty),
309 "/x?page=2"
310 );
311 }
312
313 #[test]
314 fn drop_keeps_the_path_and_the_fact_that_parameters_existed() {
315 assert_eq!(
316 sanitize_target("/x?token=a&b=2", QueryLogMode::Drop, &[]),
317 "/x?<dropped>"
318 );
319 assert_eq!(sanitize_target("/x", QueryLogMode::Drop, &[]), "/x");
320 }
321
322 #[test]
323 fn full_is_verbatim() {
324 assert_eq!(
325 sanitize_target("/x?token=supersecret", QueryLogMode::Full, &[]),
326 "/x?token=supersecret"
327 );
328 }
329
330 #[test]
333 fn the_shipped_reference_config_parses_the_log_section() {
334 let cfg: crate::config::Config = toml::from_str(include_str!("../edgeguard.toml"))
335 .expect("edgeguard.toml must deserialize into Config");
336 assert_eq!(cfg.log.query, QueryLogMode::Redact);
337 assert!(cfg.log.redact_params.is_empty());
338 }
339
340 #[test]
341 fn the_default_mode_redacts() {
342 assert_eq!(QueryLogMode::default(), QueryLogMode::Redact);
345 }
346}
347
348#[cfg(test)]
349mod encoding_tests {
350 use super::*;
351
352 fn red(target: &str) -> String {
353 sanitize_target(target, QueryLogMode::Redact, &[]).into_owned()
354 }
355
356 #[test]
357 fn a_percent_encoded_name_does_not_slip_past_the_name_list() {
358 assert_eq!(
361 red("/x?%74%6f%6b%65%6e=secret"),
362 "/x?%74%6f%6b%65%6e=<redacted>"
363 );
364 assert_eq!(red("/x?api%5Fkey=secret"), "/x?api%5Fkey=<redacted>");
365 assert_eq!(
367 red("/x?%50%61%73%73%77%6F%72%64=hunter2"),
368 "/x?%50%61%73%73%77%6F%72%64=<redacted>"
369 );
370 }
371
372 #[test]
373 fn a_percent_encoded_jwt_is_still_caught_by_shape() {
374 let jwt = "eyJhbGciOiJIUzI1NiJ9%2EeyJzdWIiOiIxIn0%2Eabc-_123";
376 assert_eq!(red(&format!("/x?t={jwt}")), "/x?t=<redacted>");
377 }
378
379 #[test]
380 fn plus_is_treated_as_a_space_when_classifying() {
381 assert_eq!(red("/x?access+token=v"), "/x?access+token=<redacted>");
383 }
384
385 #[test]
386 fn decoding_does_not_make_ordinary_values_look_sensitive() {
387 assert_eq!(
389 red("/x?q=hello%20world&page=2"),
390 "/x?q=hello%20world&page=2"
391 );
392 assert_eq!(red("/x?name=Jos%C3%A9"), "/x?name=Jos%C3%A9");
393 }
394
395 #[test]
396 fn a_stray_percent_is_not_an_escape_and_does_not_panic() {
397 for t in ["/x?q=100%", "/x?q=%zz", "/x?%=1", "/x?q=%2", "/x?%GG%=v"] {
399 let _ = red(t);
400 }
401 assert_eq!(decode_component("100%"), "100%");
402 assert_eq!(decode_component("%zz"), "%zz");
403 }
404
405 #[test]
406 fn the_output_keeps_the_original_encoding() {
407 assert_eq!(red("/x?q=a%20b"), "/x?q=a%20b");
410 }
411}