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}