use base64::{Engine as _, engine::general_purpose::STANDARD};
use percent_encoding::percent_decode_str;
use std::fmt;
use ::url::{Position, Url};
static CREDENTIAL_MASK: &str = "***";
#[derive(Clone, Copy)]
pub struct SafeUrl<'a> {
url: &'a Url,
}
impl<'a> SafeUrl<'a> {
pub fn new(url: &'a Url) -> Self {
Self { url }
}
pub fn to_url(&self) -> Url {
let mut url = self.url.clone();
url.set_query(None);
url.set_fragment(None);
if !url.username().is_empty() {
let _ = url.set_username("");
}
if url.password().is_some() {
let _ = url.set_password(None);
}
url
}
pub fn to_mask_url(&self) -> Url {
let mut url = self.url.clone();
url.set_query(None);
url.set_fragment(None);
if !url.username().is_empty() {
let _ = url.set_username(CREDENTIAL_MASK);
}
if url.password().is_some() {
let _ = url.set_password(Some(CREDENTIAL_MASK));
}
url
}
#[cfg(feature = "config-observability")]
pub(crate) fn without_credentials(self) -> UrlWithoutCredentials<'a> {
UrlWithoutCredentials { url: self.url }
}
}
#[cfg(feature = "config-observability")]
#[derive(Clone, Copy)]
pub(crate) struct UrlWithoutCredentials<'a> {
url: &'a Url,
}
#[cfg(feature = "config-observability")]
impl fmt::Display for UrlWithoutCredentials<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.url[..Position::BeforeUsername])?;
f.write_str(&self.url[Position::BeforeHost..Position::AfterPath])
}
}
#[cfg(feature = "config-observability")]
impl fmt::Debug for UrlWithoutCredentials<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl fmt::Display for SafeUrl<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.url[..Position::BeforeUsername])?;
let has_username = !self.url.username().is_empty();
let has_password = self.url.password().is_some();
if has_username {
f.write_str(CREDENTIAL_MASK)?;
}
if has_password {
f.write_str(":")?;
f.write_str(CREDENTIAL_MASK)?;
}
if has_username || has_password {
f.write_str("@")?;
}
f.write_str(&self.url[Position::BeforeHost..Position::AfterPath])
}
}
impl fmt::Debug for SafeUrl<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let username = if self.url.username().is_empty() {
""
} else {
CREDENTIAL_MASK
};
let password = self.url.password().map(|_| CREDENTIAL_MASK);
f.debug_struct("Url")
.field("scheme", &self.url.scheme())
.field("cannot_be_a_base", &self.url.cannot_be_a_base())
.field("username", &username)
.field("password", &password)
.field("host", &self.url.host())
.field("port", &self.url.port())
.field("path", &self.url.path())
.finish()
}
}
pub fn get_safe_url(url: &Url) -> SafeUrl<'_> {
SafeUrl::new(url)
}
pub fn url_authentication(url: &Url) -> Option<String> {
let password = url.password()?;
if url.username().is_empty() {
let password = percent_decode_str(password).decode_utf8().ok()?;
if !password
.bytes()
.all(|byte| byte == b'\t' || (b' '..=b'~').contains(&byte))
{
return None;
}
Some(format!("Bearer {password}"))
} else {
let username = percent_decode_str(url.username()).collect::<Vec<_>>();
if username.contains(&b':') {
return None;
}
let mut credentials = Vec::with_capacity(username.len() + password.len() + 1);
credentials.extend(username);
credentials.push(b':');
credentials.extend(percent_decode_str(password));
Some(format!("Basic {}", STANDARD.encode(credentials)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_url_display() {
let url =
Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
.expect("URL should be valid");
assert_eq!(
"https://***:***@localhost:4443/v1",
get_safe_url(&url).to_string()
);
}
#[test]
fn test_safe_url_debug() {
let url =
Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
.expect("URL should be valid");
assert_eq!(
"Url { scheme: \"https\", cannot_be_a_base: false, username: \"***\", password: Some(\"***\"), host: Some(Domain(\"localhost\")), port: Some(4443), path: \"/v1\" }",
format!("{:?}", get_safe_url(&url))
);
}
#[test]
fn test_safe_url_to_url() {
let url =
Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
.expect("URL should be valid");
assert_eq!(
"https://localhost:4443/v1",
get_safe_url(&url).to_url().as_str()
);
assert_eq!(
"https://admin:secret@localhost:4443/v1?token=secret#access_token=secret",
url.as_str()
);
}
#[test]
fn test_safe_url_to_mask_url() {
let url =
Url::parse("https://admin:secret@localhost:4443/v1?token=secret#access_token=secret")
.expect("URL should be valid");
assert_eq!(
"https://***:***@localhost:4443/v1",
get_safe_url(&url).to_mask_url().as_str()
);
assert_eq!(
"https://admin:secret@localhost:4443/v1?token=secret#access_token=secret",
url.as_str()
);
}
#[test]
fn test_safe_url_bearer_authentication() {
let url = Url::parse("https://:token@localhost:4443/v1")
.expect("Bearer authentication URL should be valid");
assert_eq!(
"https://:***@localhost:4443/v1",
get_safe_url(&url).to_string()
);
assert_eq!(
"Url { scheme: \"https\", cannot_be_a_base: false, username: \"\", password: Some(\"***\"), host: Some(Domain(\"localhost\")), port: Some(4443), path: \"/v1\" }",
format!("{:?}", get_safe_url(&url))
);
}
#[test]
fn test_url_authentication_basic() {
let basic_auth_target = Url::parse("http://user:pass@localhost:8080")
.expect("Basic auth target URL should be valid");
assert_eq!(
Some(String::from("Basic dXNlcjpwYXNz")),
url_authentication(&basic_auth_target)
);
}
#[test]
fn test_url_encoded_authentication_basic() {
let basic_auth_target = Url::parse("http://us%40er:p%25%3A%C3%A4ss@localhost:8080")
.expect("Basic auth target URL should be valid");
assert_eq!(
Some(format!("Basic {}", STANDARD.encode("us@er:p%:äss"))),
url_authentication(&basic_auth_target)
);
}
#[test]
fn test_url_authentication_rejects_colon_in_basic_username() {
let basic_auth_target = Url::parse("http://us%3Aer:password@localhost:8080")
.expect("Basic auth target URL should be valid");
assert_eq!(None, url_authentication(&basic_auth_target));
}
#[test]
fn test_url_authentication_bearer() {
let bearer_auth_target = Url::parse("http://:token%25%40%3A@localhost:8080")
.expect("Bearer auth target URL should be valid");
assert_eq!(
Some(String::from("Bearer token%@:")),
url_authentication(&bearer_auth_target)
);
}
#[test]
fn test_url_authentication_rejects_control_characters_in_bearer() {
let bearer_auth_target = Url::parse("http://:token%0D%0AX-Test%3Ayes@localhost:8080")
.expect("Bearer auth target URL should be valid");
assert_eq!(None, url_authentication(&bearer_auth_target));
}
#[test]
fn test_url_authentication_rejects_non_ascii_bearer() {
let bearer_auth_target = Url::parse("http://:token%E2%9C%93@localhost:8080")
.expect("Bearer auth target URL should be valid");
assert_eq!(None, url_authentication(&bearer_auth_target));
}
#[test]
fn test_url_authentication_rejects_non_utf8_bearer() {
let bearer_auth_target = Url::parse("http://:%FF@localhost:8080")
.expect("Bearer auth target URL should be valid");
assert_eq!(None, url_authentication(&bearer_auth_target));
}
}