Skip to main content

apollo_redaction/strategy/
url.rs

1use crate::RedactionStrategy;
2use std::fmt;
3use url::Url;
4
5/// Redact the password component of a URL, preserving all other components.
6///
7/// If the URL contains a password component, it is replaced with `***`. All other components —
8/// scheme, username, host, port, path, query, fragment — are preserved. The output is the URL as
9/// normalised by the `url` crate (for example, a trailing `/` may be added to URLs with an
10/// authority and no path, and an empty password is dropped entirely along with its `:` separator).
11/// If the input cannot be parsed as a URL, the output is `[REDACTED: invalid URL]`.
12///
13/// ```rust
14/// use apollo_redaction::Redacted;
15/// use apollo_redaction::strategy::UrlPasswordRedactor;
16///
17/// fn url(input: &str) -> String {
18///     Redacted::<_, UrlPasswordRedactor>::new(input).to_string()
19/// }
20///
21/// assert_eq!(url("http://alice:s3cr3t@proxy.corp.example.com:3128/"), "http://alice:***@proxy.corp.example.com:3128/");
22/// assert_eq!(url("postgres://user:hunter2@db.internal/app"), "postgres://user:***@db.internal/app");
23/// assert_eq!(url("http://proxy.example.com:3128"), "http://proxy.example.com:3128/");
24/// assert_eq!(url("not a url"), "[REDACTED: invalid URL]");
25/// ```
26#[derive(Default, Clone)]
27pub struct UrlPasswordRedactor;
28
29impl<T: AsRef<str>> RedactionStrategy<T> for UrlPasswordRedactor {
30    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        redact_url_password(value.as_ref(), f)
32    }
33}
34
35fn redact_url_password(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36    let Ok(mut parsed) = Url::parse(input) else {
37        return write!(f, "[REDACTED: invalid URL]");
38    };
39
40    if parsed.password().is_some() {
41        // set_password only fails for cannot-be-a-base URLs; a URL with a password
42        // always has authority, so this will succeed.
43        let _ = parsed.set_password(Some("***"));
44    }
45    write!(f, "{}", parsed)
46}
47
48/// Redact URLs, removing the scheme, hostname, port, auth, query values, and fragments.
49///
50/// ```rust
51/// use apollo_redaction::Redacted;
52/// use apollo_redaction::strategy::UrlHostRedactor;
53///
54/// fn url(input: &str) -> String {
55///     Redacted::<_, UrlHostRedactor>::new(input).to_string()
56/// }
57///
58/// assert_eq!(url("https://example.com/api/secret"), "****/api/secret");
59/// assert_eq!(url("https://example.com"), "****");
60/// assert_eq!(url("postgres://user:pass@host/db"), "****/db");
61/// assert_eq!(url("https://example.com/api?key=secret"), "****/api?key=****");
62/// assert_eq!(url("https://example.com/page#section"), "****/page#****");
63/// ```
64#[derive(Default, Clone)]
65pub struct UrlHostRedactor;
66
67impl<T: AsRef<str>> RedactionStrategy<T> for UrlHostRedactor {
68    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        redact_url(value.as_ref(), f)
70    }
71}
72
73fn redact_url(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74    let Ok(parsed) = Url::parse(input) else {
75        return write!(f, "[REDACTED: invalid URL]");
76    };
77
78    write!(f, "****")?;
79
80    // Write path (the url crate returns "/" for URLs with authority even when no path)
81    let path = parsed.path();
82    if path != "/" {
83        write!(f, "{}", path)?;
84    }
85
86    // Write redacted query parameters
87    if parsed.query().is_some() {
88        write!(f, "?")?;
89        let mut first = true;
90        for (key, _value) in parsed.query_pairs() {
91            if !first {
92                write!(f, "&")?;
93            }
94            first = false;
95            write!(f, "{}=****", key)?;
96        }
97    }
98
99    // Write redacted fragment
100    if parsed.fragment().is_some() {
101        write!(f, "#****")?;
102    }
103
104    Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::Redacted;
111
112    fn url(input: &str) -> String {
113        Redacted::<_, UrlHostRedactor>::new(input).to_string()
114    }
115
116    #[test]
117    fn test_https_with_path() {
118        assert_eq!(url("https://example.com/api/secret"), "****/api/secret");
119    }
120
121    #[test]
122    fn test_no_path() {
123        assert_eq!(url("https://example.com"), "****");
124    }
125
126    #[test]
127    fn test_with_credentials() {
128        assert_eq!(url("postgres://user:pass@host/db"), "****/db");
129    }
130
131    #[test]
132    fn test_invalid_url() {
133        assert_eq!(url("not a url"), "[REDACTED: invalid URL]");
134    }
135
136    #[test]
137    fn test_with_query_string() {
138        assert_eq!(
139            url("https://example.com/api?key=secret"),
140            "****/api?key=****"
141        );
142    }
143
144    #[test]
145    fn test_with_multiple_query_params() {
146        assert_eq!(
147            url("https://example.com/api?key=secret&token=abc123"),
148            "****/api?key=****&token=****"
149        );
150    }
151
152    #[test]
153    fn test_with_query_flag_no_value() {
154        assert_eq!(
155            url("https://example.com/api?debug&key=secret"),
156            "****/api?debug=****&key=****"
157        );
158    }
159
160    #[test]
161    fn test_with_fragment() {
162        assert_eq!(url("https://example.com/page#section"), "****/page#****");
163    }
164
165    #[test]
166    fn test_with_query_and_fragment() {
167        assert_eq!(
168            url("https://example.com/api?key=secret#section"),
169            "****/api?key=****#****"
170        );
171    }
172
173    #[test]
174    fn test_query_without_path() {
175        assert_eq!(url("https://example.com?key=secret"), "****?key=****");
176    }
177
178    #[test]
179    fn test_fragment_without_path() {
180        assert_eq!(url("https://example.com#section"), "****#****");
181    }
182
183    fn mask(input: &str) -> String {
184        Redacted::<_, UrlPasswordRedactor>::new(input).to_string()
185    }
186
187    #[test]
188    fn password_is_replaced_with_stars() {
189        assert_eq!(
190            mask("http://alice:s3cr3t@proxy.corp.example.com:3128/"),
191            "http://alice:***@proxy.corp.example.com:3128/"
192        );
193    }
194
195    #[test]
196    fn url_without_explicit_path_is_normalised_by_url_crate() {
197        // The url crate normalises URLs with authority by adding a trailing "/".
198        assert_eq!(
199            mask("http://alice:s3cr3t@proxy.corp.example.com:3128"),
200            "http://alice:***@proxy.corp.example.com:3128/"
201        );
202    }
203
204    #[test]
205    fn postgres_url_password_is_replaced() {
206        assert_eq!(
207            mask("postgres://user:hunter2@db.internal/app"),
208            "postgres://user:***@db.internal/app"
209        );
210    }
211
212    #[test]
213    fn url_without_password_is_normalised() {
214        assert_eq!(
215            mask("http://proxy.example.com:3128"),
216            "http://proxy.example.com:3128/"
217        );
218    }
219
220    #[test]
221    fn url_with_username_but_no_password_is_normalised() {
222        assert_eq!(
223            mask("http://alice@proxy.example.com"),
224            "http://alice@proxy.example.com/"
225        );
226    }
227
228    #[test]
229    fn empty_password_is_normalised_away() {
230        // The url crate treats `user:` with no password as no userinfo password at all and
231        // drops the trailing colon during normalisation, so there is nothing to mask.
232        assert_eq!(
233            mask("postgres://user:@db.internal/app"),
234            "postgres://user@db.internal/app"
235        );
236    }
237
238    #[test]
239    fn invalid_url_produces_redacted_placeholder() {
240        assert_eq!(mask("not a url"), "[REDACTED: invalid URL]");
241    }
242
243    #[test]
244    fn works_with_url_type() {
245        let url: Url = "postgres://user:hunter2@db.internal/app".parse().unwrap();
246        let redacted = Redacted::<_, UrlPasswordRedactor>::new(url);
247        assert_eq!(redacted.to_string(), "postgres://user:***@db.internal/app");
248    }
249}