Skip to content

Commit 25e3e02

Browse files
committed
fix(security): redact URL userinfo in scheme-validation error messages
A URL configured with embedded credentials (e.g. https://user:pw@host/) would be echoed verbatim into error messages and logs on rejection, leaking the password. Strip userinfo before formatting; for unparseable URLs, don't echo the raw input at all. Addresses Copilot review on #63.
1 parent 8938221 commit 25e3e02

File tree

1 file changed

+41
-2
lines changed

1 file changed

+41
-2
lines changed

src-tauri/src/mail/url_validation.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
use crate::error::{Error, Result};
22

3+
/// Format a URL for display in error messages with userinfo (user:password)
4+
/// stripped so credentials can't leak into logs or surfaced error text.
5+
/// Unparseable URLs are reported as `<invalid URL>` to avoid echoing back
6+
/// raw input that may also contain secrets.
7+
fn redact_url(url: &str) -> String {
8+
match url::Url::parse(url) {
9+
Ok(mut parsed) => {
10+
let _ = parsed.set_username("");
11+
let _ = parsed.set_password(None);
12+
parsed.into()
13+
}
14+
Err(_) => "<invalid URL>".to_string(),
15+
}
16+
}
17+
318
/// Reject URLs that would send credentials over cleartext.
419
///
520
/// Accepts `https://` URLs. In debug builds, `http://` is permitted for
@@ -8,14 +23,15 @@ use crate::error::{Error, Result};
823
/// URLs unconditionally.
924
pub fn require_https(url: &str) -> Result<()> {
1025
let parsed = url::Url::parse(url)
11-
.map_err(|e| Error::Other(format!("Invalid URL '{}': {}", url, e)))?;
26+
.map_err(|e| Error::Other(format!("Invalid URL: {}", e)))?;
1227

1328
match parsed.scheme() {
1429
"https" => Ok(()),
1530
"http" if cfg!(debug_assertions) && is_loopback_host(parsed.host_str()) => Ok(()),
1631
scheme => Err(Error::Other(format!(
1732
"URL must use https:// (got '{}'): {}",
18-
scheme, url
33+
scheme,
34+
redact_url(url)
1935
))),
2036
}
2137
}
@@ -94,4 +110,27 @@ mod tests {
94110
assert!(require_https("http://8.8.8.8").is_err());
95111
assert!(require_https("http://[2001:db8::1]").is_err());
96112
}
113+
114+
#[test]
115+
fn error_message_redacts_userinfo() {
116+
let err = require_https("http://user:secret@example.com/path")
117+
.unwrap_err()
118+
.to_string();
119+
assert!(!err.contains("secret"), "password leaked in error: {}", err);
120+
assert!(!err.contains("user:"), "userinfo leaked in error: {}", err);
121+
assert!(err.contains("example.com"), "expected host in error: {}", err);
122+
}
123+
124+
#[test]
125+
fn error_message_for_unparseable_url_does_not_echo_input() {
126+
// Something that looks like userinfo but isn't a valid URL.
127+
let err = require_https("http://user:topsecret!garbled")
128+
.unwrap_err()
129+
.to_string();
130+
assert!(
131+
!err.contains("topsecret"),
132+
"parse-failure error leaked input: {}",
133+
err
134+
);
135+
}
97136
}

0 commit comments

Comments
 (0)