prosa_utils/config/
url.rs1use base64::{Engine as _, engine::general_purpose::STANDARD};
4use percent_encoding::percent_decode_str;
5use std::fmt;
6
7use ::url::{Position, Url};
8
9static CREDENTIAL_MASK: &str = "***";
10
11#[derive(Clone, Copy)]
20pub struct SafeUrl<'a> {
21 url: &'a Url,
22}
23
24impl<'a> SafeUrl<'a> {
25 pub fn new(url: &'a Url) -> Self {
28 Self { url }
29 }
30
31 pub fn to_url(&self) -> Url {
33 let mut url = self.url.clone();
34 url.set_query(None);
35 url.set_fragment(None);
36 if !url.username().is_empty() {
37 let _ = url.set_username("");
38 }
39 if url.password().is_some() {
40 let _ = url.set_password(None);
41 }
42
43 url
44 }
45
46 pub fn to_mask_url(&self) -> Url {
48 let mut url = self.url.clone();
49 url.set_query(None);
50 url.set_fragment(None);
51 if !url.username().is_empty() {
52 let _ = url.set_username(CREDENTIAL_MASK);
53 }
54 if url.password().is_some() {
55 let _ = url.set_password(Some(CREDENTIAL_MASK));
56 }
57
58 url
59 }
60
61 #[cfg(feature = "config-observability")]
62 pub(crate) fn without_credentials(self) -> UrlWithoutCredentials<'a> {
63 UrlWithoutCredentials { url: self.url }
64 }
65}
66
67#[cfg(feature = "config-observability")]
68#[derive(Clone, Copy)]
69pub(crate) struct UrlWithoutCredentials<'a> {
70 url: &'a Url,
71}
72
73#[cfg(feature = "config-observability")]
74impl fmt::Display for UrlWithoutCredentials<'_> {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(&self.url[..Position::BeforeUsername])?;
77 f.write_str(&self.url[Position::BeforeHost..Position::AfterPath])
78 }
79}
80
81#[cfg(feature = "config-observability")]
82impl fmt::Debug for UrlWithoutCredentials<'_> {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 fmt::Display::fmt(self, f)
85 }
86}
87
88impl fmt::Display for SafeUrl<'_> {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(&self.url[..Position::BeforeUsername])?;
91
92 let has_username = !self.url.username().is_empty();
93 let has_password = self.url.password().is_some();
94
95 if has_username {
96 f.write_str(CREDENTIAL_MASK)?;
97 }
98 if has_password {
99 f.write_str(":")?;
100 f.write_str(CREDENTIAL_MASK)?;
101 }
102 if has_username || has_password {
103 f.write_str("@")?;
104 }
105
106 f.write_str(&self.url[Position::BeforeHost..Position::AfterPath])
107 }
108}
109
110impl fmt::Debug for SafeUrl<'_> {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 let username = if self.url.username().is_empty() {
113 ""
114 } else {
115 CREDENTIAL_MASK
116 };
117 let password = self.url.password().map(|_| CREDENTIAL_MASK);
118
119 f.debug_struct("Url")
120 .field("scheme", &self.url.scheme())
121 .field("cannot_be_a_base", &self.url.cannot_be_a_base())
122 .field("username", &username)
123 .field("password", &password)
124 .field("host", &self.url.host())
125 .field("port", &self.url.port())
126 .field("path", &self.url.path())
127 .finish()
128 }
129}
130
131pub fn get_safe_url(url: &Url) -> SafeUrl<'_> {
151 SafeUrl::new(url)
152}
153
154pub fn url_authentication(url: &Url) -> Option<String> {
176 let password = url.password()?;
177
178 if url.username().is_empty() {
179 let password = percent_decode_str(password).decode_utf8().ok()?;
180 if !password
181 .bytes()
182 .all(|byte| byte == b'\t' || (b' '..=b'~').contains(&byte))
183 {
184 return None;
185 }
186 Some(format!("Bearer {password}"))
187 } else {
188 let username = percent_decode_str(url.username()).collect::<Vec<_>>();
189 if username.contains(&b':') {
190 return None;
191 }
192
193 let mut credentials = Vec::with_capacity(username.len() + password.len() + 1);
194 credentials.extend(username);
195 credentials.push(b':');
196 credentials.extend(percent_decode_str(password));
197 Some(format!("Basic {}", STANDARD.encode(credentials)))
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_safe_url_display() {
207 let url =
208 Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
209 .expect("URL should be valid");
210
211 assert_eq!(
212 "https://***:***@localhost:4443/v1",
213 get_safe_url(&url).to_string()
214 );
215 }
216
217 #[test]
218 fn test_safe_url_debug() {
219 let url =
220 Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
221 .expect("URL should be valid");
222
223 assert_eq!(
224 "Url { scheme: \"https\", cannot_be_a_base: false, username: \"***\", password: Some(\"***\"), host: Some(Domain(\"localhost\")), port: Some(4443), path: \"/v1\" }",
225 format!("{:?}", get_safe_url(&url))
226 );
227 }
228
229 #[test]
230 fn test_safe_url_to_url() {
231 let url =
232 Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
233 .expect("URL should be valid");
234
235 assert_eq!(
236 "https://localhost:4443/v1",
237 get_safe_url(&url).to_url().as_str()
238 );
239 assert_eq!(
240 "https://admin:secret@localhost:4443/v1?token=secret#access_token=secret",
241 url.as_str()
242 );
243 }
244
245 #[test]
246 fn test_safe_url_to_mask_url() {
247 let url =
248 Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
249 .expect("URL should be valid");
250
251 assert_eq!(
252 "https://***:***@localhost:4443/v1",
253 get_safe_url(&url).to_mask_url().as_str()
254 );
255 assert_eq!(
256 "https://admin:secret@localhost:4443/v1?token=secret#access_token=secret",
257 url.as_str()
258 );
259 }
260
261 #[test]
262 fn test_safe_url_bearer_authentication() {
263 let url = Url::parse("https://:token@localhost:4443/v1")
264 .expect("Bearer authentication URL should be valid");
265
266 assert_eq!(
267 "https://:***@localhost:4443/v1",
268 get_safe_url(&url).to_string()
269 );
270 assert_eq!(
271 "Url { scheme: \"https\", cannot_be_a_base: false, username: \"\", password: Some(\"***\"), host: Some(Domain(\"localhost\")), port: Some(4443), path: \"/v1\" }",
272 format!("{:?}", get_safe_url(&url))
273 );
274 }
275
276 #[test]
277 fn test_url_authentication_basic() {
278 let basic_auth_target = Url::parse("http://user:pass@localhost:8080")
279 .expect("Basic auth target URL should be valid");
280 assert_eq!(
281 Some(String::from("Basic dXNlcjpwYXNz")),
282 url_authentication(&basic_auth_target)
283 );
284 }
285
286 #[test]
287 fn test_url_encoded_authentication_basic() {
288 let basic_auth_target = Url::parse("http://us%40er:p%25%3A%C3%A4ss@localhost:8080")
289 .expect("Basic auth target URL should be valid");
290 assert_eq!(
291 Some(format!("Basic {}", STANDARD.encode("us@er:p%:äss"))),
292 url_authentication(&basic_auth_target)
293 );
294 }
295
296 #[test]
297 fn test_url_authentication_rejects_colon_in_basic_username() {
298 let basic_auth_target = Url::parse("http://us%3Aer:password@localhost:8080")
299 .expect("Basic auth target URL should be valid");
300 assert_eq!(None, url_authentication(&basic_auth_target));
301 }
302
303 #[test]
304 fn test_url_authentication_bearer() {
305 let bearer_auth_target = Url::parse("http://:token%25%40%3A@localhost:8080")
306 .expect("Bearer auth target URL should be valid");
307 assert_eq!(
308 Some(String::from("Bearer token%@:")),
309 url_authentication(&bearer_auth_target)
310 );
311 }
312
313 #[test]
314 fn test_url_authentication_rejects_control_characters_in_bearer() {
315 let bearer_auth_target = Url::parse("http://:token%0D%0AX-Test%3Ayes@localhost:8080")
316 .expect("Bearer auth target URL should be valid");
317 assert_eq!(None, url_authentication(&bearer_auth_target));
318 }
319
320 #[test]
321 fn test_url_authentication_rejects_non_ascii_bearer() {
322 let bearer_auth_target = Url::parse("http://:token%E2%9C%93@localhost:8080")
323 .expect("Bearer auth target URL should be valid");
324 assert_eq!(None, url_authentication(&bearer_auth_target));
325 }
326
327 #[test]
328 fn test_url_authentication_rejects_non_utf8_bearer() {
329 let bearer_auth_target = Url::parse("http://:%FF@localhost:8080")
330 .expect("Bearer auth target URL should be valid");
331 assert_eq!(None, url_authentication(&bearer_auth_target));
332 }
333}