Skip to main content

edgeguard/
accesslog.rs

1//! Access-log sanitisation — keeping credentials out of the request line.
2//!
3//! The access log records `path?query` for every request. That is the single most useful field in
4//! it and the easiest place to leak a secret: password-reset links, magic-login links, presigned
5//! URLs, `?api_key=…`, `?email=…` and OAuth `?code=…` all travel in the query string, and an access
6//! log is the most-copied artifact a service produces — scraped, shipped to a SIEM, retained for
7//! months, and readable by people who were never meant to hold the credential.
8//!
9//! A proxy that advertises DLP should not be the component that writes them to disk. So the request
10//! target is sanitised before it reaches the log line, on two independent signals:
11//!
12//!   * **By name** — a parameter whose key is a known credential/PII name ([`SENSITIVE_KEYS`], plus
13//!     whatever the operator adds). Catches the common cases exactly.
14//!   * **By shape** — a value that looks like a credential regardless of what it is called: a JWT,
15//!     or a long high-entropy token. Catches `?t=eyJhbGciOi…`, which a name list never will.
16//!
17//! Neither is complete on its own and the pair is not complete either; `Drop` is there for
18//! deployments that would rather lose the debugging value than reason about it. What is *not*
19//! sanitised is the path itself — `/reset/<token>` is indistinguishable from `/users/<id>` without
20//! knowing the application's routes, and guessing would mangle ordinary paths. Applications that put
21//! secrets in path segments need `Drop` plus care.
22
23use serde::{Deserialize, Serialize};
24
25/// What to do with the query string in the access log.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum QueryLogMode {
29    /// Redact values that are sensitive by name or by shape; keep the rest. The default: an access
30    /// log stays useful for debugging without carrying credentials.
31    #[default]
32    Redact,
33    /// Log the path only. The query is replaced by `?<dropped>` when one was present, so the log
34    /// still shows that parameters existed.
35    Drop,
36    /// Log the target verbatim. Opt-in, for a deployment that has decided its logs are as sensitive
37    /// as its traffic and is protecting them accordingly.
38    Full,
39}
40
41/// Query-parameter names treated as credentials or personal data.
42///
43/// Matched case-insensitively as a **substring**, so `api_key`, `X-Api-Key` and `apikey_v2` all hit
44/// on `key`. Substring matching over-redacts (`monkey=1` is caught by `key`); in an access log that
45/// is the right direction to be wrong in.
46pub 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
67/// The replacement written in place of a redacted value. Fixed, not derived from the value, so the
68/// log leaks neither the secret nor its length.
69const REDACTED: &str = "<redacted>";
70
71/// Percent- and plus-decode a query component, for **classification only**.
72///
73/// Query components arrive encoded, and both signals below match on the decoded meaning:
74/// `?%74%6f%6b%65%6e=secret` is `?token=secret` to every recipient, but matches no entry in
75/// [`SENSITIVE_KEYS`] as written. A JWT with its dots encoded as `%2E` slips past the shape check
76/// the same way. Classifying on the raw text alone therefore redacts exactly the credentials that
77/// were not obfuscated.
78///
79/// Returns borrowed when there is nothing to decode, which is the overwhelming majority of
80/// components. Invalid UTF-8 in the decoded bytes is replaced rather than rejected — this feeds a
81/// substring match, and a lossy character cannot make a sensitive name look innocuous.
82fn 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                    // A stray `%` that is not an escape: keep it literally.
99                    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
118/// Whether a parameter name is sensitive, given the operator's additions.
119///
120/// Checked against the raw text **and** its decoded form: either tripping is enough. Encoding is a
121/// way to hide a name from a substring match, and in a log the right direction to be wrong in is
122/// redacting too much.
123fn 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
135/// Whether a value *looks* like a credential whatever it is called.
136///
137/// Two shapes, both chosen to be cheap and to have a low false-positive rate on ordinary query
138/// values (page numbers, slugs, dates, sort keys):
139///   * a JWT — three base64url segments separated by dots, with a plausible header segment;
140///   * a long unbroken run of token alphabet — 24+ chars of base64url/hex with no separators. Real
141///     query values that long are usually opaque identifiers, and redacting those costs little.
142fn 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        // A long value with no digits is far more likely to be a slug or a sentence than a token.
156        && 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    // `eyJ` is base64url for `{"`, which every JWT header starts with.
167    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
177/// Sanitise a request target (`/path?a=1&b=2`) for the access log.
178///
179/// Returns the path unchanged when there is no query string, so the common case allocates nothing
180/// beyond the borrow it is handed.
181pub 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            // A bare flag (`?debug`) carries no value to leak.
205            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        // Mixed: the useful half of the line survives.
249        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        // The case a name list cannot catch, and the reason shape matching exists.
265        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        // Prose, slugs, dates and ids stay readable — the point of not simply dropping everything.
273        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        // An empty entry must not match everything.
306        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    /// The reference config is what an operator copies; a `[log]` section it documents but the
331    /// code cannot parse fails at boot, after deploy.
332    #[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        // A safe default is the whole point: an operator who never reads this config still does not
343        // ship credentials to their SIEM.
344        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        // `%74%6f%6b%65%6e` is `token`. Classifying on the raw text alone would redact the
359        // credentials nobody bothered to obfuscate and log the ones they did.
360        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        // Mixed case in the escape is still an escape.
366        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        // `%2E` is `.`, so the three-segment shape only appears after decoding.
375        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        // Form encoding: `access+token` is `access token`, which still contains "token".
382        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        // The redaction must still leave a usable log behind.
388        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        // Malformed input reaches this from the network; it must degrade, not crash.
398        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        // Only classification decodes. Rewriting the logged text would change what the request
408        // actually said, which is the one thing an access log is for.
409        assert_eq!(red("/x?q=a%20b"), "/x?q=a%20b");
410    }
411}