Skip to main content

av_core/
url_redact.rs

1//! Shared URL-userinfo redaction for logs and diagnostics.
2//!
3//! The `upstream_url` field is
4//! validated to only require an `http(s)://` scheme, so an operator
5//! whose provider requires HTTP Basic auth (a common pattern with
6//! self-hosted litellm/proxy shims) can legitimately put credentials
7//! in the URL as `https://user:pass@host/…`. Every tracing/log
8//! statement that renders that field then ships the credentials to
9//! OTLP/SIEM. `avctl doctor` already redacts it; this helper is the
10//! shared implementation so every log site can do the same.
11//!
12//! Semantics: keep the scheme + host(:port)(+ path/query/fragment)
13//! intact so log correlation still works; replace any `user[:pass]`
14//! segment with `***`. Handle comma-separated cluster lists (Redis
15//! Cluster style: `redis://user:pass@host1,host2,host3`) without
16//! splitting on commas that belong to a password.
17
18/// Redact URL userinfo. Non-URL inputs are returned verbatim.
19///
20/// Handles two shapes used across the codebase:
21///
22/// 1. Multiple full URLs joined with commas — the historical shape
23///    `avctl doctor` prints (`redis://u:p@h1,redis://u:p@h2`). Each
24///    segment is processed independently.
25/// 2. A single URL whose authority contains a cluster host list —
26///    Redis Cluster URI grammar `redis://user:pass@h1:port,h2:port`.
27///    The whole authority is treated as one segment and userinfo is
28///    stripped before the LAST `@` — which stays inside the userinfo
29///    even when the password itself contains `,`.
30///
31/// # Examples
32///
33/// ```
34/// use av_core::url_redact::redact_userinfo;
35///
36/// assert_eq!(
37///     redact_userinfo("https://alice:secret@api.example.com/v1"),
38///     "https://***@api.example.com/v1",
39/// );
40/// // Password containing `,` (a legal RFC 3986 sub-delim):
41/// assert_eq!(
42///     redact_userinfo("redis://u:p,w@host1:6379,host2:6379"),
43///     "redis://***@host1:6379,host2:6379",
44/// );
45/// // Comma-joined list of complete URLs:
46/// assert_eq!(
47///     redact_userinfo("redis://u:p@h1:7000,redis://u:p@h2:7001"),
48///     "redis://***@h1:7000,redis://***@h2:7001",
49/// );
50/// assert_eq!(redact_userinfo("https://api.example.com/v1"), "https://api.example.com/v1");
51/// assert_eq!(redact_userinfo("plain text"), "plain text");
52/// ```
53pub fn redact_userinfo(input: &str) -> String {
54    if !input.contains("://") {
55        return input.to_owned();
56    }
57    // Heuristic split: if the input contains multiple `://` occurrences
58    // AND commas that START a new URL (i.e. are immediately followed by
59    // `scheme://`), it's a list of full URLs — process each
60    // independently. A comma inside a password or query never precedes
61    // a scheme, so it stays inside its segment. The prior naive
62    // `split(',')` cut a comma-carrying password in half whenever the
63    // SAME URL's query held a second URL (`https://u:pa,ss@h/cb?u=http://x`):
64    // both halves then failed the userinfo heuristics and the whole
65    // secret was returned verbatim.
66    let scheme_count = input.matches("://").count();
67    if scheme_count > 1 {
68        return split_url_list(input)
69            .into_iter()
70            .map(redact_single)
71            .collect::<Vec<_>>()
72            .join(",");
73    }
74    redact_single(input)
75}
76
77/// Split at commas immediately followed by a `scheme://` prefix (RFC
78/// 3986 scheme grammar: ALPHA then ALPHA/DIGIT/`+`/`-`/`.`). Commas
79/// inside passwords, cluster host lists, or queries never match.
80fn split_url_list(input: &str) -> Vec<&str> {
81    let mut segments = Vec::new();
82    let mut start = 0;
83    for (index, _) in input.match_indices(',') {
84        if index >= start && starts_with_scheme(&input[index + 1..]) {
85            segments.push(&input[start..index]);
86            start = index + 1;
87        }
88    }
89    segments.push(&input[start..]);
90    segments
91}
92
93fn starts_with_scheme(s: &str) -> bool {
94    let Some(scheme) = s.find("://").map(|end| &s[..end]) else {
95        return false;
96    };
97    let mut bytes = scheme.bytes();
98    bytes.next().is_some_and(|first| first.is_ascii_alphabetic())
99        && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
100}
101
102fn redact_single(input: &str) -> String {
103    let Some(scheme_end) = input.find("://") else {
104        return input.to_owned();
105    };
106    let auth_start = scheme_end + 3;
107    let scheme_prefix = &input[..auth_start];
108    let rest = &input[auth_start..];
109    // RFC-correct case first: the authority ends at the first `/`,
110    // `?`, or `#`, and the last `@` within it splits userinfo from
111    // host (a password may legally contain `,` — sub-delims). This
112    // also keeps `@` characters later in the path/query (mailto-style
113    // tokens) out of the decision.
114    let auth_end_off = rest.find(['/', '?', '#']).unwrap_or(rest.len());
115    let authority = rest.get(..auth_end_off).unwrap_or_default();
116    let trailing = rest.get(auth_end_off..).unwrap_or_default();
117    if let Some((_, host_segment)) = authority.rsplit_once('@') {
118        return format!("{scheme_prefix}***@{host_segment}{trailing}");
119    }
120    // No `@` inside the RFC authority, but one later in the string:
121    // either a password containing a raw `/`, `?`, or `#` (routine in
122    // base64-style secrets — a shape that used to be returned
123    // VERBATIM, defeating redaction) or a credential-less URL with an
124    // `@` in its path (`http://h/p@th`), which must stay untouched.
125    // Discriminate by the naive authority's shape: a truncated
126    // `user:password` leaves a `:` followed by non-digit characters,
127    // while a legitimate credential-less authority is `host`,
128    // `host:port` (digits only after the colon), or a bracketed IPv6
129    // literal (whose colons are address syntax, never a userinfo
130    // separator — RFC 3986 requires the brackets precisely so `:` in
131    // the address cannot be confused with the port delimiter). Known
132    // residual gaps: a password whose pre-`/` prefix is all digits or
133    // contains `[`/`]` still leaks — the mixed-character secrets this
134    // defends against are covered.
135    let truncated_userinfo = !authority.contains('[')
136        && authority
137            .rsplit_once(':')
138            .is_some_and(|(_, suffix)| !suffix.is_empty() && !suffix.bytes().all(|b| b.is_ascii_digit()));
139    if !truncated_userinfo {
140        return input.to_owned();
141    }
142    match rest.rfind('@') {
143        Some(at) => {
144            let after_at = rest.get(at + 1..).unwrap_or_default();
145            let host_end = after_at.find(['/', '?', '#']).unwrap_or(after_at.len());
146            let host_segment = after_at.get(..host_end).unwrap_or_default();
147            let tail = after_at.get(host_end..).unwrap_or_default();
148            format!("{scheme_prefix}***@{host_segment}{tail}")
149        }
150        None => input.to_owned(),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn https_with_password() {
160        assert_eq!(
161            redact_userinfo("https://alice:secret@api.example.com/v1/chat"),
162            "https://***@api.example.com/v1/chat",
163        );
164    }
165
166    #[test]
167    fn https_without_userinfo_unchanged() {
168        assert_eq!(
169            redact_userinfo("https://api.example.com/v1/chat"),
170            "https://api.example.com/v1/chat",
171        );
172    }
173
174    #[test]
175    fn redis_cluster_list_with_comma_password() {
176        // `,` inside the password used to break the per-segment splitter.
177        assert_eq!(
178            redact_userinfo("redis://user:p,w@host1:6379,host2:6379"),
179            "redis://***@host1:6379,host2:6379",
180        );
181    }
182
183    #[test]
184    fn redis_cluster_no_userinfo() {
185        assert_eq!(
186            redact_userinfo("redis://host1:6379,host2:6379"),
187            "redis://host1:6379,host2:6379",
188        );
189    }
190
191    /// A comma-carrying password in a URL whose QUERY holds a second
192    /// URL used to leak in full: two `://` occurrences tripped the
193    /// naive `split(',')`, the cut segments failed every userinfo
194    /// heuristic, and both halves of the secret were returned
195    /// verbatim. The list splitter must only split at commas that
196    /// start a new `scheme://`.
197    #[test]
198    fn comma_password_with_nested_url_in_query_still_redacts() {
199        assert_eq!(
200            redact_userinfo("https://alice:se,cret@host/cb?u=http://inner"),
201            "https://***@host/cb?u=http://inner",
202        );
203        // The doctor's comma-joined URL list still splits correctly,
204        // including when a listed URL carries a comma password (the
205        // inner comma is not followed by a scheme, so it stays put).
206        assert_eq!(
207            redact_userinfo("redis://u:p,w@h1:7000,redis://u:pw@h2:7001"),
208            "redis://***@h1:7000,redis://***@h2:7001",
209        );
210        // Comma in a query, multiple schemes, no userinfo: untouched.
211        assert_eq!(
212            redact_userinfo("https://host/a?list=x,http://y"),
213            "https://host/a?list=x,http://y",
214        );
215    }
216
217    #[test]
218    fn plain_string_untouched() {
219        assert_eq!(redact_userinfo("hello world"), "hello world");
220        assert_eq!(redact_userinfo(""), "");
221    }
222
223    /// Regression: a one-character credential is also a prefix of the
224    /// host in `:://T@\x00%TTT`. The product redacts to
225    /// `:://***@\x00%TTT`, which is correct — the credential is gone
226    /// from the userinfo slot. This case exists so a future change
227    /// cannot "fix" the false alarm by weakening the redaction.
228    #[test]
229    fn single_character_credential_is_redacted() {
230        let input = ":://T@\x00%TTT";
231        let expected = ":://***@\x00%TTT";
232        assert_eq!(redact_userinfo(input), expected);
233        // Idempotent.
234        assert_eq!(redact_userinfo(expected), expected);
235        // The userinfo slot really is `***`.
236        let out = redact_userinfo(input);
237        let scheme_end = out.find("://");
238        assert_eq!(scheme_end, Some(1), "output lost its scheme: {out}");
239        let rest = &out[scheme_end.map_or(0, |end| end + 3)..];
240        let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
241        assert_eq!(
242            authority.rsplit_once('@').map(|(u, _)| u).unwrap_or(""),
243            "***",
244            "userinfo slot is not the redaction marker: {out}"
245        );
246    }
247
248    #[test]
249    fn query_fragment_after_authority() {
250        assert_eq!(
251            redact_userinfo("https://u:p@host/x?token=@abc#frag"),
252            "https://***@host/x?token=@abc#frag",
253        );
254    }
255
256    #[test]
257    fn password_with_path_query_fragment_chars_still_redacts() {
258        // Base64-style secrets routinely contain `/`; `?` and `#` are
259        // also possible in operator-pasted passwords. These shapes used
260        // to be returned verbatim (the first path/query/fragment byte
261        // truncated the authority before the `@` was found).
262        for (input, expected) in [
263            ("redis://user:AB/CDEF@host:6379", "redis://***@host:6379"),
264            ("redis://user:AB?CD@host:6379", "redis://***@host:6379"),
265            ("redis://user:AB#CD@host:6379", "redis://***@host:6379"),
266            (
267                "https://user:AB/CD@api.example.com/v1",
268                "https://***@api.example.com/v1",
269            ),
270            // Credential-less shapes with a raw `@` in the path stay
271            // untouched (the naive authority is host / host:port).
272            ("http://h/p@th", "http://h/p@th"),
273            ("http://host:8080/p@th", "http://host:8080/p@th"),
274            // Bracketed IPv6 literals: address colons are never a
275            // userinfo separator.
276            ("http://[::1]/p@th", "http://[::1]/p@th"),
277            (
278                "https://[2001:db8::1]:8443/p@th",
279                "https://[2001:db8::1]:8443/p@th",
280            ),
281        ] {
282            assert_eq!(redact_userinfo(input), expected, "input: {input}");
283        }
284    }
285}