Skip to main content

prosa_utils/config/
url.rs

1//! URL authentication and safe-formatting utilities.
2
3use 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/// Borrowed URL view for safe logging and display.
12///
13/// [`Display`](fmt::Display) and [`Debug`](fmt::Debug) mask URL credentials and omit the query
14/// and fragment without cloning the underlying [`Url`]. [`SafeUrl::to_url`] returns an owned URL
15/// with credentials removed, while [`SafeUrl::to_mask_url`] returns one with masked credentials.
16///
17/// The URL path is preserved and can still contain sensitive information. Callers must avoid
18/// putting secrets in paths or apply additional application-specific redaction.
19#[derive(Clone, Copy)]
20pub struct SafeUrl<'a> {
21    url: &'a Url,
22}
23
24impl<'a> SafeUrl<'a> {
25    /// Create a borrowed URL view that masks credentials and omits query and fragment when
26    /// formatted.
27    pub fn new(url: &'a Url) -> Self {
28        Self { url }
29    }
30
31    /// Return an owned URL without credentials, query, or fragment.
32    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    /// Return an owned URL with masked credentials and without query or fragment.
47    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
131/// Return a borrowed URL view with masked credentials and without query or fragment.
132///
133/// Formatting the returned view does not clone or reparse the URL. Use [`SafeUrl::to_url`] when an
134/// owned URL without credentials is required, or [`SafeUrl::to_mask_url`] when the owned URL must
135/// retain masked user information.
136///
137/// ```
138/// use prosa_utils::config::url::get_safe_url;
139/// use url::Url;
140///
141/// let url =
142///     Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
143///         .unwrap();
144/// let safe_url = get_safe_url(&url);
145///
146/// assert_eq!(safe_url.to_string(), "https://***:***@localhost:4443/v1");
147/// assert_eq!(safe_url.to_url().as_str(), "https://localhost:4443/v1");
148/// assert_eq!(safe_url.to_mask_url().as_str(), "https://***:***@localhost:4443/v1");
149/// ```
150pub fn get_safe_url(url: &Url) -> SafeUrl<'_> {
151    SafeUrl::new(url)
152}
153
154/// Build an HTTP authorization value from URL credentials.
155///
156/// URL percent-encoding is decoded before constructing the authentication value. Basic
157/// authentication usernames containing a decoded `:` are rejected because the character
158/// separates the username and password. Bearer credentials that are not valid ASCII HTTP header
159/// values are rejected.
160///
161/// - A non-empty username and password produce Basic authentication.
162/// - An empty username and password produce Bearer authentication using the password as the token.
163/// - A URL without a password produces `None`.
164///
165/// ```
166/// use url::Url;
167/// use prosa_utils::config::url::url_authentication;
168///
169/// let basic_auth_target = Url::parse("http://user:pass@localhost:8080").unwrap();
170/// assert_eq!(Some(String::from("Basic dXNlcjpwYXNz")), url_authentication(&basic_auth_target));
171///
172/// let bearer_auth_target = Url::parse("http://:token@localhost:8080").unwrap();
173/// assert_eq!(Some(String::from("Bearer token")), url_authentication(&bearer_auth_target));
174/// ```
175pub 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}