Skip to main content

espresso_utils/
redact.rs

1//! Redaction of credentials embedded in provider URLs. L1 RPC providers put the API key in the
2//! path or query (`https://host/v2/KEY`), so the host is kept and everything after it dropped. A
3//! credential in the hostname is not redactable.
4//!
5//! [`scrub`] works on already-rendered text, which is the only handle available when the URL is
6//! baked into someone else's `Debug`/`Display` (`reqwest` prints the full request URL in both).
7//! [`redact_url`] takes a [`Url`] directly.
8//!
9//! It handles two shapes that share no common substring. `Url`'s own `Debug` is field-wise, with the
10//! host and `path: "/v2/KEY"` as separate fields and no `://` between them, so URL-shaped matching
11//! misses it entirely. Structs holding a [`Url`] redact that field themselves; [`scrub_url_debug`]
12//! is the net for one that does not.
13use url::Url;
14
15const REDACTED: &str = "***";
16
17/// Characters `Url::as_str()` percent-encodes in every component, so they cannot occur inside a
18/// rendered URL. Nothing may be added here without that guarantee: `)`, `,`, `|` and `^` are legal
19/// in a path, and `` ` ``, `{` and `}` are legal in a query or fragment; ending a URL on any of them
20/// would leave the tail after it in the output.
21fn ends_url_token(c: char) -> bool {
22    c == ' ' || c.is_control() || matches!(c, '"' | '<' | '>')
23}
24
25/// Replaces everything after the host of every URL in `text` with `***`.
26///
27/// Assumes the text was rendered from a parsed [`Url`], which percent-encodes the boundary
28/// characters above. Credential-bearing URLs satisfy this by entering as clap-parsed [`Url`]s, so a
29/// malformed one is rejected at startup rather than reaching a log line.
30fn scrub_urls(text: &str) -> String {
31    let mut out = String::with_capacity(text.len());
32    let mut pos = 0;
33
34    while let Some(rel) = text[pos..].find("://") {
35        // "://" is ASCII, so +3 lands on a char boundary.
36        let start = pos + rel + 3;
37        out.push_str(&text[pos..start]);
38
39        let end = text[start..]
40            .find(ends_url_token)
41            .map(|k| start + k)
42            .unwrap_or(text.len());
43        let token = &text[start..end];
44        let authority = token.split(['/', '?', '#']).next().unwrap_or(token);
45        // Userinfo precedes the host and can carry a password.
46        let host = authority.rsplit('@').next().unwrap_or(authority);
47
48        out.push_str(host);
49        if host.len() != token.len() {
50            out.push('/');
51            out.push_str(REDACTED);
52        }
53        pos = end;
54    }
55
56    out.push_str(&text[pos..]);
57    out
58}
59
60const URL_DEBUG_PREFIX: &str = "Url {";
61
62/// Collapses the body of every `url::Url` field-wise `Debug` in `text`, host included. Reaching
63/// this means a struct failed to redact a `Url` field, so `Url { *** }` is a signal to fix that
64/// struct rather than output to read a host out of.
65fn scrub_url_debug(text: &str) -> String {
66    let mut out = String::with_capacity(text.len());
67    let mut pos = 0;
68
69    while let Some(rel) = text[pos..].find(URL_DEBUG_PREFIX) {
70        let start = pos + rel;
71        let body_start = start + URL_DEBUG_PREFIX.len();
72        // Require a type-name boundary, so `BaseUrl {` and friends keep their bodies.
73        if text[..start]
74            .chars()
75            .next_back()
76            .is_some_and(|c| c.is_alphanumeric() || c == '_')
77        {
78            out.push_str(&text[pos..body_start]);
79            pos = body_start;
80            continue;
81        }
82        out.push_str(&text[pos..body_start]);
83        // Field values are quoted and `"` is percent-encoded in every URL component, so a `}`
84        // outside quotes closes the body. Braces are legal in a query or fragment, so the first
85        // `}` in the text may sit inside one.
86        let mut in_quotes = false;
87        let end = text[body_start..]
88            .char_indices()
89            .find_map(|(i, c)| match c {
90                '"' => {
91                    in_quotes = !in_quotes;
92                    None
93                },
94                '}' if !in_quotes => Some(i),
95                _ => None,
96            });
97        let Some(k) = end else {
98            // Truncated: drop the remainder rather than emit an unterminated body.
99            pos = text.len();
100            break;
101        };
102        out.push_str(" *** ");
103        pos = body_start + k;
104    }
105
106    out.push_str(&text[pos..]);
107    out
108}
109
110pub fn scrub(text: &str) -> String {
111    scrub_url_debug(&scrub_urls(text))
112}
113
114/// `scheme://host[:port]`, with `/***` appended when anything was removed.
115pub fn redact_url(url: &Url) -> String {
116    let scheme = url.scheme();
117    let Some(host) = url.host() else {
118        return format!("{scheme}:{REDACTED}");
119    };
120    let port = match url.port() {
121        Some(port) => format!(":{port}"),
122        None => String::new(),
123    };
124    let removed = !url.username().is_empty()
125        || url.password().is_some()
126        || !matches!(url.path(), "" | "/")
127        || url.query().is_some()
128        || url.fragment().is_some();
129    let tail = match removed {
130        true => format!("/{REDACTED}"),
131        false => String::new(),
132    };
133    format!("{scheme}://{host}{port}{tail}")
134}
135
136pub fn redact_urls<'a>(urls: impl IntoIterator<Item = &'a Url>) -> Vec<String> {
137    urls.into_iter().map(redact_url).collect()
138}
139
140#[cfg(test)]
141mod test {
142    use super::*;
143
144    #[test]
145    fn test_scrub() {
146        let cases: &[(&str, &str)] = &[
147            // The shape observed leaking in telemetry: reqwest's Debug.
148            (
149                r#"Transport(Custom(reqwest::Error { kind: Request, url: "https://rpc.example.com/v1/FAKEKEY", source: hyper::Error(IncompleteMessage) }))"#,
150                r#"Transport(Custom(reqwest::Error { kind: Request, url: "https://rpc.example.com/***", source: hyper::Error(IncompleteMessage) }))"#,
151            ),
152            // reqwest's Display. The closing paren is absorbed, since `)` is legal in a path.
153            (
154                "error sending request for url (https://host/v1/FAKEKEY)",
155                "error sending request for url (https://host/***",
156            ),
157            ("https://host/v2/FAKEKEY", "https://host/***"),
158            ("https://host/rpc?apikey=FAKEKEY", "https://host/***"),
159            ("https://host/p#frag", "https://host/***"),
160            ("http://host:8545/k", "http://host:8545/***"),
161            ("wss://host/v2/FAKEKEY", "wss://host/***"),
162            ("https://[::1]:8545/FAKEKEY", "https://[::1]:8545/***"),
163            ("https://user:pass@host/x", "https://host/***"),
164            // Legal in a path or query, so these must not end the URL.
165            ("https://host/a)b,FAKEKEY suffix", "https://host/*** suffix"),
166            ("https://host/a|FAKEKEY", "https://host/***"),
167            ("https://host/rpc?t={FAKEKEY}", "https://host/***"),
168            (
169                "a https://h1/k1 b https://h2/k2 c",
170                "a https://h1/*** b https://h2/*** c",
171            ),
172            (
173                "日本 https://host/FAKEKEY 中文",
174                "日本 https://host/*** 中文",
175            ),
176            ("http://localhost:8545", "http://localhost:8545"),
177            ("no url here", "no url here"),
178            ("://", "://"),
179            ("", ""),
180        ];
181        for (input, expected) in cases {
182            assert_eq!(&scrub(input), expected, "input: {input:?}");
183            let once = scrub(input);
184            assert_eq!(scrub(&once), once, "not idempotent: {input:?}");
185        }
186    }
187
188    /// The dominant shape in real telemetry: host and path are separate fields with no `://`, so
189    /// URL-shaped matching misses it.
190    #[test]
191    fn test_scrub_url_debug() {
192        let raw = format!(
193            "urls: [{:?}]",
194            Url::parse("https://rpc.example.com/v3/FAKEKEY").unwrap()
195        );
196        assert!(raw.contains("FAKEKEY"), "{raw}");
197        assert!(!raw.contains("://"), "{raw}");
198
199        let scrubbed = scrub(&raw);
200        assert_eq!(scrubbed, "urls: [Url { *** }]");
201        assert_eq!(scrub(&scrubbed), scrubbed);
202    }
203
204    /// Braces are legal in a query or fragment, so the first `}` may sit inside a field value.
205    #[test]
206    fn test_scrub_url_debug_brace_in_query_or_fragment() {
207        for raw in [
208            "https://rpc.example.com/v1?filter={a}&apikey=FAKEKEY",
209            "https://rpc.example.com/v1#{a}FAKEKEY",
210        ] {
211            let dbg = format!("{:?}", Url::parse(raw).unwrap());
212            assert_eq!(scrub_url_debug(&dbg), "Url { *** }", "input: {raw}");
213        }
214    }
215
216    /// Only `url::Url` is collapsed; a type whose name merely ends in `Url` keeps its body.
217    #[test]
218    fn test_scrub_url_debug_requires_type_boundary() {
219        let text = r#"BaseUrl { host: "keep.me" }"#;
220        assert_eq!(scrub_url_debug(text), text);
221
222        // Truncated debug output must not emit the partial body.
223        let cut = r#"urls: [Url { scheme: "https", path: "/v3/FAKEKEY""#;
224        assert!(!scrub_url_debug(cut).contains("FAKEKEY"));
225    }
226
227    #[test]
228    fn test_redact_url() {
229        let cases: &[(&str, &str)] = &[
230            (
231                "https://host.example.com/v1/FAKEKEY",
232                "https://host.example.com/***",
233            ),
234            ("http://host:8545/k", "http://host:8545/***"),
235            ("https://user:pass@host/x", "https://host/***"),
236            ("https://host/rpc?apikey=FAKEKEY", "https://host/***"),
237            ("https://[::1]:8545/FAKEKEY", "https://[::1]:8545/***"),
238            ("http://localhost:8545", "http://localhost:8545"),
239            ("mailto:foo@example.com", "mailto:***"),
240        ];
241        for (input, expected) in cases {
242            let url = Url::parse(input).unwrap();
243            assert_eq!(&redact_url(&url), expected, "input: {input:?}");
244        }
245    }
246}