1use url::Url;
16
17const REDACTED: &str = "***";
19
20const REDACTED_CONNECTION_STRING: &str = "<redacted connection string>";
22
23pub fn sanitize_connection_string(conn_str: &str) -> String {
42 if !is_uri_like(conn_str) {
46 let redacted = redact_keyword_password(conn_str);
47 if redacted != conn_str {
48 return redacted;
49 }
50 }
51
52 #[cfg(feature = "pg_kvbackend")]
53 if let Ok(config) = conn_str.parse::<tokio_postgres::Config>() {
54 return format!("{config:?}");
56 }
57
58 if let Ok(url) = Url::parse(conn_str) {
59 return redact_url(url);
60 }
61
62 if is_uri_like(conn_str) {
63 return REDACTED_CONNECTION_STRING.to_string();
64 }
65
66 redact_keyword_password(conn_str)
67}
68
69fn redact_url(mut url: Url) -> String {
71 if url.password().is_some() {
72 let _ = url.set_password(Some(REDACTED));
73 }
74 if url.query_pairs().any(|(key, _)| key == "password") {
75 let redacted: Vec<(String, String)> = url
76 .query_pairs()
77 .map(|(key, value)| {
78 let value = if key == "password" {
79 REDACTED.to_string()
80 } else {
81 value.into_owned()
82 };
83 (key.into_owned(), value)
84 })
85 .collect();
86 url.query_pairs_mut().clear().extend_pairs(redacted);
87 }
88 url.to_string()
89}
90
91fn is_uri_like(s: &str) -> bool {
97 let s = s.trim_start();
98 let Some(pos) = s.find("://") else {
99 return false;
100 };
101 let mut chars = s[..pos].chars();
102 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
103 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
104}
105
106fn redact_keyword_password(s: &str) -> String {
112 const KEY: &str = "password";
113 let mut out = String::with_capacity(s.len());
114 let mut i = 0;
115 while i < s.len() {
116 if keyword_matches_at(s, i, KEY) {
117 let mut j = skip_ws(s, i + KEY.len());
118 if s[j..].starts_with('=') {
119 if is_keyword_at(s, i, KEY) {
120 j = skip_ws(s, j + 1);
121 out.push_str("password=");
122 out.push_str(REDACTED);
123 i = skip_value(s, j);
124 continue;
125 }
126
127 let embedded_in_identifier = s[..i]
128 .chars()
129 .next_back()
130 .is_some_and(|c| c.is_alphanumeric() || c == '_');
131 if !embedded_in_identifier {
132 return REDACTED_CONNECTION_STRING.to_string();
133 }
134 }
135 }
136 let ch = s[i..].chars().next().unwrap();
137 out.push(ch);
138 i += ch.len_utf8();
139 }
140 out
141}
142
143fn keyword_matches_at(s: &str, i: usize, keyword: &str) -> bool {
144 s[i..]
145 .get(..keyword.len())
146 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(keyword))
147}
148
149fn skip_ws(s: &str, mut i: usize) -> usize {
150 while let Some(ch) = s[i..].chars().next() {
151 if ch.is_whitespace() {
152 i += ch.len_utf8();
153 } else {
154 break;
155 }
156 }
157 i
158}
159
160fn is_keyword_at(s: &str, i: usize, keyword: &str) -> bool {
166 keyword_matches_at(s, i, keyword)
167 && (i == 0
168 || s[..i]
169 .chars()
170 .next_back()
171 .is_some_and(|c| c.is_whitespace() || matches!(c, ';' | '&')))
172}
173
174fn skip_value(s: &str, start: usize) -> usize {
176 if start >= s.len() {
177 return start;
178 }
179 let quote = s[start..].chars().next().unwrap();
180 let quoted = quote == '\'' || quote == '"';
181 let mut i = if quoted {
182 start + quote.len_utf8()
183 } else {
184 start
185 };
186 while i < s.len() {
187 let ch = s[i..].chars().next().unwrap();
188 if ch == '\\' {
189 i += ch.len_utf8();
190 if let Some(next) = s[i..].chars().next() {
191 i += next.len_utf8(); }
193 continue;
194 }
195 if quoted {
196 if ch == quote {
197 return i + ch.len_utf8(); }
199 } else if ch.is_whitespace() {
200 return i;
201 }
202 i += ch.len_utf8();
203 }
204 s.len()
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_sanitize_non_pg_url() {
213 let s = sanitize_connection_string("mysql://user:password123@localhost:3306/db");
214 assert!(!s.contains("password123"), "{s}");
215 assert!(s.contains("user:***@localhost:3306"), "{s}");
216
217 let s = sanitize_connection_string("mysql://localhost:3306/db");
218 assert!(s.contains("localhost:3306"), "{s}");
219
220 let s = sanitize_connection_string("mysql://user@localhost:3306/db?password=secret");
221 assert!(!s.contains("secret"), "{s}");
222
223 let s = sanitize_connection_string("http://etcd-host:2379");
224 assert!(s.contains("etcd-host:2379"), "{s}");
225 }
226
227 #[test]
233 fn test_sanitize_fail_closed_uri() {
234 for dsn in [
235 "postgresql://user:secret@host1:1234,host2:5678/db?unknown=x",
236 " postgresql://user:secret@host1:1234,host2:5678/db?unknown=x",
237 "\u{2003}postgresql://user:secret@host1:1234,host2:5678/db?unknown=x",
238 "postgresql://user@host1:1,host2:2/db?pass%77ord=qsecret&unknown=x",
239 ] {
240 let s = sanitize_connection_string(dsn);
241 assert!(!s.contains("secret"), "leaked for {dsn:?}: {s}");
243 assert!(!s.contains("host1"), "not fully redacted for {dsn:?}: {s}");
245 }
246 }
247
248 #[test]
249 fn test_sanitize_malformed_keyword_fail_closed() {
250 for dsn in [
251 "host=localhost;password=LEAK_CANARY",
252 "host=localhost&password=LEAK_CANARY",
253 ] {
254 let s = sanitize_connection_string(dsn);
255 assert!(!s.contains("LEAK_CANARY"), "leaked for {dsn:?}: {s}");
256 }
257
258 for dsn in [
259 "host=localhost,password=LEAK_CANARY",
260 "host=localhost/password=LEAK_CANARY",
261 "host=localhost?password=LEAK_CANARY",
262 "host=localhost#password=LEAK_CANARY",
263 "host=localhost:password=LEAK_CANARY",
264 "host=localhost,PASSWORD=LEAK_CANARY",
265 "application_name=password=LEAK_CANARY",
266 "password=a host=localhost,password=LEAK_CANARY",
267 "host=h,password=LEAK_CANARY password=b",
268 "user=u password=a host=h/password=LEAK_CANARY",
269 ] {
270 let s = sanitize_connection_string(dsn);
271 assert_eq!(REDACTED_CONNECTION_STRING, s, "for {dsn:?}");
272 }
273
274 for (dsn, expected) in [
275 (
276 "PASSWORD=LEAK_CANARY host=localhost",
277 "password=*** host=localhost",
278 ),
279 (
280 "host=localhost Password=LEAK_CANARY",
281 "host=localhost password=***",
282 ),
283 ] {
284 let s = sanitize_connection_string(dsn);
285 assert_eq!(expected, s, "for {dsn:?}");
286 }
287
288 for dsn in ["notpassword=LEAK_CANARY", "notPassword=LEAK_CANARY"] {
289 let s = sanitize_connection_string(dsn);
290 assert!(s.contains("LEAK_CANARY"), "over-redacted for {dsn:?}: {s}");
291 }
292 }
293
294 #[cfg(not(feature = "pg_kvbackend"))]
298 #[test]
299 fn test_sanitize_keyword() {
300 for (dsn, ok) in [
301 (
302 "host=localhost password=secret dbname=x",
303 "host=localhost password=*** dbname=x",
304 ),
305 (
306 "host=localhost password = secret dbname=x",
307 "host=localhost password=*** dbname=x",
308 ),
309 (
310 "host=localhost password='my secret' dbname=x",
311 "host=localhost password=*** dbname=x",
312 ),
313 (
314 "host=localhost password=\"my secret\" dbname=x",
315 "host=localhost password=*** dbname=x",
316 ),
317 (
318 r"host=localhost password='pa\'ss' dbname=x",
319 "host=localhost password=*** dbname=x",
320 ),
321 (
322 "host=localhost user=password dbname=x",
323 "host=localhost user=password dbname=x",
324 ),
325 (
327 "host=localhost password=secret application_name=svc://a",
328 "host=localhost password=*** application_name=svc://a",
329 ),
330 (
333 "host=localhost password=secret&suffix dbname=x",
334 "host=localhost password=*** dbname=x",
335 ),
336 (
337 r"host=localhost password=secret\ suffix dbname=x",
338 "host=localhost password=*** dbname=x",
339 ),
340 ("password=secret;host=localhost", "password=***"),
342 ] {
343 assert_eq!(sanitize_connection_string(dsn), ok, "for {dsn:?}");
344 }
345
346 for dsn in [
348 "host=localhost\u{2003}password=secret",
349 "host=localhost password\u{2003}=\u{2003}secret",
350 ] {
351 assert!(
352 !sanitize_connection_string(dsn).contains("secret"),
353 "leaked for {dsn:?}"
354 );
355 }
356 }
357
358 #[cfg(feature = "pg_kvbackend")]
361 #[test]
362 fn test_sanitize_pg_dsn() {
363 for dsn in [
364 "postgresql://user:secret@localhost:5432/db",
365 "postgresql://user:secret@host1:1234,host2,host3:5678/db",
366 "postgresql://user@host1:1,host2/db?pass%77ord=secret",
367 "host=localhost port=5432 user=postgres password=secret dbname=mydb",
368 "host=localhost\u{2003}password=secret",
369 "password=secret;host=localhost",
370 "host=localhost password=secret application_name=http://client",
371 ] {
372 assert!(
373 !sanitize_connection_string(dsn).contains("secret"),
374 "leaked for {dsn:?}"
375 );
376 }
377 }
378}